feat: #164 sponsored ERC-4337 register + v2-demo harness restructure#200
Merged
Conversation
…s restructure Broker-sponsored, gas-free ERC-4337 master onboarding (#164 E6/E7): new broker 'sponsor' module — verifiable UserOp + VerifyingPaymaster.getHash encoding with an EIP-191 broker co-sign (pure functions, byte-exact with the live contracts, zero-gas read-only verification); lib.rs exports it. CLI gains k11 webauthn passkey keygen/sign + the sponsored-register flow; daemon ui_bridge wires the flow into the desktop UI. Harness + docs: v2-demo.sh restructured into a single 5-phase front door (1-3 stages, 4 memory-plant, 5 wire) with PHASE.STEP addressing; sandbox auto-detect now probes the aiosandbox HTTP API (not a local openviking install). v2-stage3 agent-side steps (11-12/14-15) DEFER to the sandbox on the operator (green) with mock reserved for CI; clearer stale-broker guidance on the #195 master-self step. stage1/2 default to Touch-ID WebAuthn for operators, stub for CI. Adds harness/CLAUDE.md (harness rules extracted from root CLAUDE.md), operator runbooks (harness, web-memory), and erc4337 register/fund helpers.
… don't trip a stale Cargo.lock setup-broker-host.sh --ref did 'git checkout -f' + 'git pull --ff-only', which can no-op against a stale local branch tip or leave a build-modified Cargo.lock on disk. A subsequent 'cargo build --locked' then fails with 'cannot update the lock file'. A deploy target must match origin EXACTLY — replace the ff-only pull with 'git reset --hard origin/$PULL_REF' (HEAD + index + working tree, Cargo.lock included). Idempotent.
…nt -> success) Closes the gap where only the NEGATIVE/skip scope paths were asserted. Worker verify.rs: two new unit tests with an in-process std::net JSON-RPC mock (no new dep, Cargo.lock untouched) — check_chain_scope_ok_when_chain_grants (operator!=actor, chain returns true -> Ok) and check_chain_scope_rejects_when_chain_denies (false -> NotInScope). Harness v2-stage3: new standalone step 18 'POSITIVE: granted agent (operator!=actor) mints memory cap for the GRANTED service -> 200' — extracts the scope-grant assertion out of the steps 11-12 roundtrip; operator-authenticated mint (no agent key), defers on a §10.2 agent whose device isn't paired yet, mocks on CI. Completes the scope triad with step 16 (master-self skip) + step 17 (cross-actor denied). Cleanup renumbered 18->19, STEP_TOTAL=19. Runbook updated.
…{evm_address} (HTTP 422)
Phase 4 step 2 POSTed {evm_address:...} to /v1/auth/wallet/start, but the broker's WalletStartRequest requires {address: String, chain_id: u64} (both mandatory) — so axum rejected it with 422 'missing field address'. Reproduced live: {evm_address} -> 422, {address,chain_id} -> 200. Aligns memory-plant with the broker contract + the shape stage-1/stage-3/web-memory-bootstrap already use. Broker was correct; this was a stale client field name.
…P 400 malformed address) DEPLOYER_ADDR is already 0x-prefixed (cast wallet address output), but step 2 prepended another 0x -> '0x0x941cb1…' -> broker 400 'malformed address'. The wrong field name (422, prior fix) had masked this. Reproduced live: 0x0x… -> 400, 0x… -> 200. The omni (line 74) + cap-mint (line 98) already use the correct forms (broker hashes agentkeysevm+0x-addr, verified in omni_account.rs). Also switch the wallet start/verify curls from -sSf to -sS --fail-with-body so the broker's error JSON is shown on 4xx instead of an opaque 'curl: (NN) … error: CODE' (this step hid its cause twice).
[high] Bash 3.2 (memory-plant-demo.sh): dropped the 'declare -A CAP' associative array (bash 4+; the operator platform is macOS bash 3.2.57 where it errors and CAP[$ns] under set -u is an unbound arithmetic var). Step 3 now just proves cap-mint per namespace; step 4 re-mints fresh (short-TTL). Verified runnable under 3.2. [high] Partial plant (daemon ui_bridge.rs): the real-chain plant only failed when ZERO entries planted, so a partial (some namespaces succeed, one fails) returned 200 + audit + updated state. Now ANY durable-write failure returns 502 before the success audit/response; succeeded writes stay in master_memory so a re-plant is idempotent and resumes. 35 ui_bridge tests still pass. [medium] Phase 5 skip (v2-demo.sh): an auto-skip (no aiosandbox) returned 0 and printed 'all green' + 'agent paired' — an unexecuted proof read as a pass. Now run_wire_phase records WIRE_RESULT (wired/skipped/disabled); an auto-skip reports 'v2-demo INCOMPLETE' and exits non-zero, the loop shows the phase as SKIPPED (not green ok), and the final pass/paired text only prints when the wire actually ran. --wire none is the explicit clean-skip escape (CI uses it). Also fixed the skip hint to the correct aiosandbox 'docker run …' (was the wrong openviking-sandbox-setup.sh). Runbook + harness/CLAUDE.md synced.
…eferred) + real memory plant dev.sh launched 'agentkeys-daemon --ui-bridge' with NO --register-master-script, so finish_chain_register hit its 'register_master_script = None' branch and silently SKIPPED the on-chain registerFirstMasterDevice (chain: none) — the ceremony was deferred while K11 enroll still reported success. It also passed no --memory-url/--memory-role-arn, so the plant button fell back to the in-memory RwLock instead of cap-mint → STS → worker → S3. dev.sh now sources scripts/operator-workstation.env and ALWAYS passes --register-master-script (in-repo heima-register-first-master.sh; a missing deployer key / chain config now surfaces chain_error, never a silent skip), plus --memory-url/--memory-role-arn/--region when the env supplies them (real plant; logged). The daemon↔script arg contract was already correct (--operator-omni/--actor-omni/--k11-cose-hex/--k11-cred-id/--rp-id-hash) and real_memory_ctx sources the device hash from the K11-finish register, so the un-deferred ceremony is exactly what feeds the plant. Name drift called out: the daemon's --memory-url env is AGENTKEYS_MEMORY_URL but operator-workstation.env spells it AGENTKEYS_WORKER_MEMORY_URL; bridged in dev.sh via the explicit flag (accepts either). Also un-stale the ui_bridge.rs module doc that still claimed the register is stubbed.
…e agent's memory scope)
Phase 5 ran 'phase1-wire-demo.sh --real' with NO --webauthn, so the wire's P.3 scope grant (heima-scope-set --webauthn) was SKIPPED — the §10.2 agent paired but the master never granted it the memory:<ns> scope. The agent's memory.get(travel) mints a cap for service 'memory:travel' (mcp-server/src/tools/memory.rs: format!("memory:{namespace}")), the broker checks isServiceInScope(O_master, agent, memory:travel) and returns service_not_in_scope -> Act1 (3.1) + inject (4.2) fail. Now auto + real pass --real --webauthn so the master grants memory:<ns> via Touch ID (one prompt, like phases 1-2; heima-scope-set is idempotent so re-runs skip). Service strings match (grant + cap both memory:<ns>); the master's K11 is enrolled+registered in phases 1-2 so setScopeWithWebauthn verifies. Runbook + harness/CLAUDE.md synced.
…nnect a daemon' toast The /memory plant button only renders when the daemon is connected (status.kind==='connected'), so a plant failure is almost never 'no daemon' — yet plantDone's else-branch always showed 'Connect a daemon to plant prepared memory', masking the daemon's actual reason (which postJson already captured in r.status.detail, e.g. 409 'no master session — complete onboarding first' / 'master device not registered on chain yet', or a 502 worker error). Now it extracts + shows the real reason. tsc --noEmit clean.
…lant 400)
memory_put_real/memory_get_real send operator_omni + actor_omni = ctx.omni, sourced from the onboarding session omni which is stored BARE (no 0x). The broker cap-mint input-validates that operator_omni starts with 0x and 400s ('operator_omni must start with 0x') before normalizing — so the web plant failed AFTER the device was registered. Normalize ctx.omni to 0x once in real_memory_ctx (covers put + get); the broker normalize_hex32's it for the device-binding match, and master-self (operator==actor) hits the #195 skip so no scope grant is needed. cargo check -p agentkeys-daemon clean.
…s-store gap) After a successful plant, plantDone read listMasterMemory but only setMemories on ok and toasted just the plant counts — so a daemon-cache miss (e.g. after a restart) silently showed an empty list. Now the toast shows '<planted> new … <list.length> in the memory view' (so 'N new but 0 in view' is visible) and surfaces a failed list GET. Note: GET /v1/master/memory reads the daemon IN-MEMORY cache, not S3 — so the list is empty after any daemon restart even though the data is durable in S3.
… (Phase 0)
Phase 0 of docs/plan/web-flow/config-data-class-memory-list.md (lazy, config-driven memory list). Adds the DataClass::Config variant to both cap.rs + verify.rs (serializes 'config'), the broker cap_config_store/cap_config_fetch handlers (statically derive {op, data_class: Config}) + routes /v1/cap/config-store + /v1/cap/config-fetch. check_data_class is generic, so a Config cap is rejected by the cred + memory workers (and a memory cap by the config worker) — covered by new unit tests. Infra-free: the endpoints mint Config caps, but the config bucket/role/worker land in Phases 1-2. cargo check (broker) clean; 4 worker data_class tests pass.
5 tasks
harness-ci 'cargo fmt + clippy + test' failed at fmt: this PR's sponsor/webauthn/cli/daemon/verify code (committed earlier) wasn't rustfmt-clean, plus my new config routes in lib.rs. Ran cargo fmt --all (6 PR files reformatted, no unrelated drift). Also fixed clippy::unusual_byte_groupings in sponsor.rs:189 (0x0102_03 -> 0x010203, value-identical) that -D warnings rejected in the lib-test target. Verified locally: fmt --check clean, clippy --workspace --all-targets -- -D warnings exit 0, cargo test --workspace 40 results ok / 0 failed.
… seed seam (W6) Implements wire-real-paths W6 as a v2-demo PHASE, not a standalone script (no second front door, no re-bootstrap). daemon: add --ui-bridge-seed-session-jwt + --ui-bridge-seed-omni — seeds the ui-bridge onboarding session with the master's existing J1 + omni so the parity phase drives the REAL plant chain WITHOUT re-running interactive email/WebAuthn onboarding (pairs with the existing --master-device-key-hash). harness/web-parity-demo.sh = phase 6: reuses the preflight build + live chain/broker + the master registered in phases 1-2, boots agentkeys-daemon --ui-bridge SEEDED, plants a probe ns via POST /v1/master/memory/plant; a 200 proves the daemon's chain (cap-mint→STS→worker→S3) == the agent/harness chain — the web↔harness drift gate. Cost: one daemon boot + one plant, no re-build/re-chain/re-enroll; real-only (skips without a broker). Wired into v2-demo (default 1→6, --from/--stage/--only addressing). Docs synced (runbook, harness/CLAUDE.md, wire-real-paths W6). cargo fmt+clippy --workspace --all-targets clean; bash -n clean. NOTE: statically verified (compiles + wired + prereqs met); the live end-to-end smoke is bash harness/v2-demo.sh --stage 6 on real infra.
This was referenced Jun 5, 2026
hanwencheng
added a commit
that referenced
this pull request
Jun 5, 2026
…rker chain The broker/worker HTTP chain was hand-coded in three places (MCP HttpBackend, daemon ui_bridge, harness bash), the structural cause of the #200 drift bugs (evm_address vs {address,chain_id}, bare-vs-0x omni, per-namespace field shapes). Collapse it behind one crate so drift is a COMPILE error (Rust callers share the types) or a FIXTURE mismatch (the harness gate), not a runtime 4xx. New crate agentkeys-backend-client (the dual of broker-server / worker-*): - protocol.rs: every cap-mint / worker / audit wire shape, the memory:<ns> service builder, and the 0x-omni normalizer (the daemon's old inline bug site) - client.rs: BackendClient — cap-mint (4 data-class endpoints) -> STS relay -> worker put/get -> audit append (the reference impl lifted out of HttpBackend) - fixtures.rs + dump-protocol-fixtures bin: canonical fixtures serialized from the serde types + frozen key-set pins Collapse the duplicates (net -355 LOC in existing files): - MCP HttpBackend -> thin delegate over BackendClient; backend wire-shape submodules (broker/memory/audit) deleted, re-exported from the crate so the Backend trait + InMemoryBackend + tools keep their crate::backend::* paths - daemon memory_put_real / real_memory_ctx -> call the shared client (kills the duplicate cap-mint body + the inline 0x-normalize where the bugs lived) Enforce (fold-systemic-fixes-into-enforcement): - scripts/check-backend-fixture-drift.sh: diffs every # @backend-fixture- annotated bash body against the crate-emitted fixtures (catches add/rename/drop) - harness-ci.yml rust-checks runs the fixture --check + the bash gate on every PR touching crates/**, harness/**, scripts/** - root CLAUDE.md + harness/CLAUDE.md "broker/worker shapes have ONE owner" rule; arch.md component inventory updated
hanwencheng
added a commit
that referenced
this pull request
Jun 6, 2026
…y list + lazy detail + codex hardening) (#205) * feat(worker-config): #201 Config data-class substrate — infra + worker + isolation tests (Phases 1-3) Stand up the DataClass::Config substrate end-to-end (Phases 1-3 of the config-driven memory-list plan; Phase 0 cap layer landed in #200). The visible daemon/frontend behavior (Phases 4-5) is a follow-up, gated on the operator deploying this (per the issue's dependency chain 4 -> 0,1,2). Phase 1 — infra (idempotent mirrors of the memory scripts): - scripts/provision-config-bucket.sh, provision-config-role.sh, apply-config-bucket-policy.sh (config/ prefix, own bucket + role per arch.md §17.2; split-statement v3 bucket policy) - CONFIG_BUCKET / CONFIG_ROLE_ARN + config worker host/URL in operator-workstation.env; wired into setup-cloud.sh step 13 Phase 2 — config worker (master-only): - new agentkeys-worker-config crate (mirror of agentkeys-worker-memory; config/ S3 prefix, $CONFIG_BUCKET, AGENTKEYS_CONFIG_KEK_HEX, DataClass::Config, :9096) - full setup-broker-host.sh wiring (build/install/env/systemd/nginx/firewall/ certbot/post-install summary) Phase 3 — isolation tests (test-discipline rule): - harness/v2-stage3-demo.sh steps 19-21: config layer-3/4 (own-prefix write OK + cross-bucket AccessDenied) + cap data-class-mismatch (config<->memory, config<->cred). All master-self -> run on the operator, no sandbox defer; skip cleanly until the operator provisions config infra + redeploys the broker. Source-of-truth updates: arch.md (§5 canonical names, §17.2/.3/.5, four-layer table, storage diagram), CLAUDE.md (per-data-class table + six cap endpoints + 'third data class landed'), operator-runbook-harness.md, harness/CLAUDE.md, plan doc. Verified: config worker dev+release build + unit tests green; cargo check --workspace clean (all 17 crates); all bash scripts syntax-clean. * fix(harness): graceful skip (not die) when a cross-class worker is unreachable post_cross_class folded curl's stderr into the returned code via 2>&1, so an UNDEPLOYED worker (e.g. config.litentry.org before the broker redeploy) yielded rc="curl: (35) SSL_ERROR_SYSCALL...\n000000" instead of a clean "000". That no longer matched master_cross_class_rejection's 000|502|503|504) case and fell through to die — turning the intended graceful prereq_missing (config-worker-unreachable) at stage-3 step 21 into a hard failure. Send curl's transport error to a side file so rc is just the 3-digit %{http_code} (000 on transport failure), and surface that error as the body for diagnostics. Also hardens steps 14-15 (same helper) — clean rc + diagnostic body. Verified: repro against the unreachable config.litentry.org returns clean 000 -> prereq_missing fires; bash -n clean. * fix(infra): #201 wire config worker into DNS + worker verify (config.litentry.org A record) The config worker host was added to operator-workstation.env but NOT to the two DNS provisioning paths nor the worker health-check, so config.litentry.org never got an A record → unreachable (the stage-3 step-21 SSL_ERROR_SYSCALL). Add WORKER_CONFIG_HOST everywhere the four original workers are enumerated: - scripts/setup-cloud.sh do_step_6 — the PRIMARY DNS path (its own change-batch, not a delegate): + config A record + env validation (8 A records / 14 UPSERTs). - scripts/dns-upsert-workers.sh — the standalone re-UPSERT path: + config in the sanity loop, change-batch, plan printout, DoH verify loop, and certbot next-steps. - scripts/verify-workers.sh — + config:/healthz ("ok":true), All 5 workers green. - operator-workstation.env — comment now says five workers incl. config. Verified: bash -n clean on all three; setup-cloud change-batch builds 14 records; dns-upsert change-batch valid JSON. * refactor(infra): #201 setup-cloud delegates worker DNS to dns-upsert-workers.sh (single source of truth) Wire the config-worker setup fully into the idempotent orchestrator so nobody runs DNS by hand, and kill the dual-maintenance drift that left config.litentry.org without an A record (two hardcoded worker lists: setup-cloud step 6 + dns-upsert). - dns-upsert-workers.sh: new --no-verify (UPSERT then exit, skipping the INSYNC/DoH wait + operator next-steps printout) for orchestrator use. - setup-cloud.sh step 6: keep DKIM/MX/TXT + broker/signer/mcp inline (9 records); DELEGATE the 5 service-worker A records (audit/email/cred/memory/config) to dns-upsert-workers.sh --eip $EIP --no-verify (honors --dry-run + the same ENV_FILE so the prod/test split carries through). One source of truth → a new worker can never again be added to one list but not the other. - The 3 config provision scripts were already delegated in step 13 (no change). - cloud-bootstrap.md: config.litentry.org added to the certbot recipe (+ explicit one-shot form), the --config-host flag, the DNS A-record list, the worker-subdomain table, the per-worker env-file glob, the build/nginx/test-subdomain references. Verified: bash -n clean on all three; setup-cloud inline batch builds 9 records; dns-upsert --no-verify parses + early-exits; cloud-bootstrap certbot loop includes CONFIG_HOST. * perf(deploy): #201 sccache compiler cache in setup-broker-host (fast re-deploys + branch switches) The broker host redeploys often and switches branches via --ref. cargo already caches deps in $REPO_ROOT/target (we never clean on the happy path), but a git checkout -f rewrites changed files' mtimes → cargo re-fingerprints + rebuilds them, and a cold/wiped target/ recompiles the whole aws-sdk/tokio tree. Add sccache — a CONTENT-addressed compiler cache keyed on each crate's actual inputs (not mtime/branch/target state), persisted in $SCCACHE_DIR independent of target/. Identical inputs hit the cache regardless of branch or a cold target/. - setup_build_cache(): installs sccache (prebuilt musl binary, arch-detected → cargo install fallback → skip), exports RUSTC_WRAPPER + SCCACHE_DIR, starts the server. Best-effort + idempotent + NON-FATAL (deploy proceeds with plain cargo if install fails). Opt out: AGENTKEYS_NO_SCCACHE=1; pin: SCCACHE_VERSION=vX.Y.Z. - Prints 'sccache stats' after the worker build — visible proof (re-deploys = mostly cache hits). - cloud-bootstrap.md documents the cache + the opt-out. Verified: bash -n clean. Note: this does NOT change what gets built; my earlier #201 commits were all shell/docs (zero Rust), so a re-run that only pulls them recompiles nothing. * docs(cloud-bootstrap): #201 cert issuance MUST run on the broker (not a VPN'd laptop) + ACME pre-check Operator hit a certbot 'unauthorized … 404' on config.litentry.org because certbot --webroot was run on a local box (behind a VPN): the challenge file landed there, but Let's Encrypt validates against the hostname's PUBLIC IP = the broker, which had no such file. The nginx 1.28.3 (VPN proxy) vs 1.24.0 (broker) version split in the 404 pages was the tell. Fold-back to §5b so the next operator can't repeat it: - Loud '⚠️ run EVERY command ON THE BROKER HOST' callout explaining the --webroot-writes-local vs CA-validates-public-IP mechanism + the WARP/Zscaler interception trap (laptop curl of <host> hits the VPN's nginx, not the broker). - A cheap local ACME pre-check (nginx reload + probe file + curl localhost with Host header) BEFORE the certbot loop — a freshly-added worker (config) needs a reload; 'nginx -T' showing the vhost does NOT mean the running process loaded it. - New troubleshooting entry for the exact 'unauthorized … 404' error covering both causes (wrong host; vhost not reloaded). Docs only; fences balanced. * fix(infra): #201 dns-upsert derives worker EIP from broker's A record (not 'first associated EIP') Root cause of the config (and all-worker) cert failures: dns-upsert-workers.sh derived the EIP via `describe-addresses | first`, which can't distinguish the PROD broker EIP from the TEST broker EIP when both are allocated. It silently grabbed the test EIP (3.214.219.209) and pointed all 5 worker A records at the test broker, while broker/signer stayed on prod (54.164.117.252). Let's Encrypt then validated config.litentry.org against the test box (404). Derive the workers' EIP from BROKER_HOST's OWN Route 53 A record instead — the workers co-locate with the broker, so their records MUST mirror it. This is env-aware (BROKER_HOST is broker.${ZONE} for prod vs test-broker.${ZONE} for test) and authoritative. Add a co-location guard that warns when the chosen/passed EIP disagrees with the broker's A record (catches a prod/test mixup early). cloud-bootstrap.md §5b gains a troubleshooting entry for 'worker cert fails but broker works' with a DoH cross-check loop. Verified live (--dry-run against the real zone): derives 54.164.117.252 and sets all 5 worker records to it; bash -n clean. * fix(infra): #201 dns-upsert derives EIP by broker tag (prod vs CI/test), matching setup-cloud step 4 Prod and the CI/test broker are SEPARATE machines with SEPARATE EIPs. The previous fix derived from broker.${ZONE}'s A record (works for prod, but chicken-egg on a fresh test box + a different mechanism than the bootstrap). Switch to the SAME tag-based, TEST_MODE-aware derivation setup-cloud.sh step 4 uses — one source of truth: prod → describe-addresses --filters Name=tag:Name,Values=agentkeys-broker-eip test → ...Values=agentkeys-broker-eip-test (--test, or a *test* ENV_FILE) - New --test flag + auto-detect from a *test* ENV_FILE (switches to operator-workstation.test.env), mirroring setup-cloud. - Keep the broker-A-record co-location cross-check as a warn-only guard. Verified live (--dry-run): prod → 54.164.117.252 (tag agentkeys-broker-eip); --test → 3.214.219.209 (tag agentkeys-broker-eip-test). bash -n clean. * docs(CLAUDE): #201 always verify the broker IP env-aware (prod vs CI/test = separate EIPs) Two broker EC2 instances exist with separate EIPs, distinguished by the EIP Name tag (agentkeys-broker-eip vs agentkeys-broker-eip-test). 'describe-addresses first-match' silently picks the wrong one — it pointed all 5 worker A records at the test broker while broker/signer were on prod (multi-round LE 404s). New AWS- gotchas subsection: never first-match; derive by the env-aware tag (setup-cloud step 4 / dns-upsert-workers.sh), curl ifconfig.me on the host, DoH-cross-check workers == broker for DNS. * perf(deploy): #201 keep Rust toolchain across broker re-deploys (the real slow-rebuild cause) setup-broker-host.sh deleted /root/.cargo + /root/.rustup at the END of every run (~1.5GB reclaim). So every re-deploy re-downloaded the WHOLE rustup toolchain + all 372 crate sources — minutes of pure waste (target/ persists in the repo dir, which is why the compile itself was only ~50s, but the toolchain+registry did not). - KEEP the toolchain by default; gate the delete behind a new --reclaim-toolchain flag (pass it on a final/one-shot deploy to free the disk). - Pre-source $HOME/.cargo/env in the build-prereqs step so a kept toolchain is on PATH on a non-login sudo shell — otherwise `have rustup` is false and it reinstalls anyway even with /root/.cargo present. - Header usage + post-run NOTE updated to reflect keep-by-default. Combined with the sccache change (86d18be), re-deploys now skip toolchain DL + crate-registry DL + most recompilation. bash -n clean. * refactor(deploy): #201 hard rule — 3 idempotent entry points + --ci env flag Per the deploy-script governance: there are exactly THREE idempotent deployment orchestrators (setup-cloud.sh / setup-broker-host.sh / setup-heima.sh); every other mutation is wired into one of them. Codify it in CLAUDE.md + standardise the environment flag. - Add --ci (canonical CI-env flag; --test retained as alias) to all 3 entry points + dns-upsert-workers.sh. Plain run = local/prod; --ci = CI (selects the agentkeys-broker-eip-test EIP, -test IAM/buckets, *.test.env). - CLAUDE.md: new 'Three idempotent deployment entry points' section (ownership table, flag convention, HARD wire-in rule, exempt list). Verified mcp-host is already wired into setup-broker-host (#152 re-converge); setup-dev-env is a dev-workstation bootstrap (exempt, not a deploy). Verified: bash -n clean; --ci --dry-run derives the test EIP (3.214.219.209). * fix(infra): #201 test stack — config data class in test env + step-13 ENV_FILE passthrough + cloud-bootstrap --ci Two real test-mode bugs + doc drift, found while fitting the scripts to cloud-bootstrap.md: - operator-workstation.test.env was MISSING the entire config (#201) data class (CONFIG_ROLE_ARN / CONFIG_BUCKET / WORKER_CONFIG_HOST / *_URL) — so setup-cloud.sh --ci / setup-broker-host.sh --ci would die on the WORKER_CONFIG_HOST validation. Added the -test trio (agentkeys-config-role-test, agentkeys-config-test-<acct>, config-test.litentry.org). - setup-cloud.sh step 13 called provision/apply-*.sh WITHOUT ENV_FILE; each re-sources operator-workstation.env (prod) and overwrites inherited CONFIG_BUCKET, so --ci would silently provision PROD buckets. Now passes ENV_FILE through (DRY loop) → -test buckets. - cloud-bootstrap.md: --test → --ci (alias noted) in quick-start; added config bucket to 'what --ci derives'; corrected the stale 'toolchain deleted each run' note to the new keep-by-default + --reclaim-toolchain behavior; called out prod vs CI = separate EIPs. Verified: test env config trio resolves; setup-cloud bash -n clean. * docs(CLAUDE): #201 codify env-file + provisioner discipline for new data classes This session's two test-mode bugs were systemic, not one-offs — fold them into the #90 isolation section's data-class checklist so the next data-class-adder can't repeat: 1. a new data class MUST be added to BOTH operator-workstation.env AND .test.env (.test.env is not auto-derived; a prod-only key breaks the whole --ci path). 2. setup-cloud.sh delegation MUST pass ENV_FILE to provision/apply helpers (they re-source prod env + overwrite inherited $BUCKET, so --ci would hit prod buckets). Includes the verify step (setup-cloud.sh --ci --dry-run must name -test resources). * fix(harness): stage-3 Totals line renders colors (escapes in format string, not %s args) The Totals summary printed literal \033[1;32m… because the C_* color vars (literal "\033[…" strings) were passed as printf %s ARGS — printf only interprets \033 in the FORMAT string, not in args. Moved the colors into the format string, matching the ${C_*}-in-format pattern used everywhere else. TTY-gated defs unchanged, so non-TTY/CI runs stay plain. Verified via cat -v (^[ = real ESC); bash -n clean. * feat(daemon,worker): #201 Phases 4-5 — Config taxonomy memory list + lazy detail + codex hardening Phase 4 (daemon): read/write the memory-types taxonomy via the Config data class (--config-url/--config-role-arn); GET /v1/master/memory returns categories from the taxonomy (no decrypt, cache fallback); new lazy GET /v1/master/memory/entry?ns=&key=; plant writes per-namespace JSON arrays. CLI hook memory-inject renders the array (single-body still injects). harness memory-plant-demo + web-parity write/pass the new shape. Phase 5 (frontend): apps/parent-control lists categories, decrypts a namespace's entries on demand; plant re-fetches categories. Codex adversarial-review hardening: - finding 1 (data loss): plant is now a read-modify-write merge under a plant_lock (durable blob preserved; abort-on-read-error, never overwrite). - finding 2 (silent failure): memory+config workers return 404 on NoSuchKey; list 502s on a configured-but-broken Config; plant returns taxonomy_status. Workers changed → requires a setup-broker-host.sh redeploy for the 404 behavior. * fix(ci): #201 stage-3 config steps — emit config env keys + tolerate config-role-missing harness-e2e crashed at stage-3 step 19 with `CONFIG_ROLE_ARN: unbound variable`: the CI env-materializer (harness-ci.yml) never emitted the config data-class keys Phase 3 added to the stage-3 demo, and the demo runs under `set -u`. - harness-ci.yml: materialize CONFIG_BUCKET / CONFIG_ROLE_ARN / AGENTKEYS_WORKER_CONFIG_URL (derived -test values, no new secret); allow the config-role-missing skip (operator one-shot, like scope-not-set) so step 19 skips cleanly until the test config bucket/role are provisioned. Steps 20-21 (config cap-mismatch) still run against the deployed config worker. - v2-stage3-demo.sh: default the config vars to empty after sourcing the env file → degrade via prereq_missing instead of an unbound-variable abort. - CLAUDE.md: fold the materializer into the env-file discipline (3rd place a new data class's keys must land). * docs(#207): onboarding/classifier design spec + policy/scope/namespace wiki Add the product/onboarding view of the classifier design (#178) on top of the landed Config substrate (#201): the two config-init entry points (default preset + NL->COMPILE), connect-time classifier auto-distribution of cred + memory scopes (one pattern, two axes), the four security invariants, and the resolved decisions tracked in #207 (telemetry split to #208). - docs/plan/web-flow/onboarding-classifier-distribution.md (new spec) - docs/wiki/policy-scope-namespace.md (new terminology reference, lint-clean) - docs/arch.md section 5 canonical-names row (policy/scope/namespace/category/service) - docs/plan/classifier-service.md cross-links * ci(harness): allow config-worker-unreachable skip on the test env (#201) stage-3 step 21 hits the config worker HTTPS endpoint (config-test.<zone>), whose cert can't issue until the config-test DNS record is provisioned by the operator one-shot (setup-cloud.sh --ci) — the SAME one-shot already tolerated via config-role-missing. Add config-worker-unreachable to the stage-3 allow-skip so CI skips step 21 cleanly until the test config infra exists; step 20 + the agentkeys-worker-config unit tests still cover the config cap-data-class-mismatch. harness/CLAUDE.md already documents steps 19-21 as 'skip until config infra is provisioned/deployed'. Drop the allowance once config-test is provisioned.
hanwencheng
added a commit
that referenced
this pull request
Jun 6, 2026
… combine, not just resolve #205 (issue #201) landed a THIRD data class (Config): /v1/cap/config-{store,fetch} + an agentkeys-worker-config worker + a hand-rolled daemon config/per-ns-memory chain. #204 (#203) made agentkeys-backend-client the ONE owner of the broker/worker protocol. Rather than let the two coexist as parallel hand-rolled vs crate-owned chains, this merge folds #205's new surface INTO the #203 single-owner model. Conflicts resolved (2 files): - ui_bridge.rs: adopt #205's per-namespace storage model wholesale (memory_put_ns_real / memory_get_ns_real / RMW-under-plant-lock / real_config_ctx) — my per-entry memory_put_real + real_memory_client are SUPERSEDED, dropped. Kept my route consts (MASTER_MEMORY_{,PLANT_}ROUTE) + the plant-contract unit test, and #205's new /v1/master/memory/entry route. Swapped #205's inline 0x-normalize in the shared resolve_session_coords for the crate's normalize_omni_0x. - memory-plant-demo.sh: keep #205's per-ns JSON-array blob + my @backend-fixture annotation. Combine (#203 applied to #205's surface): - crate: CapMintOp gains ConfigStore/ConfigFetch (6 cap endpoints now); add ConfigPutBody/ConfigGetBody + fixtures (regenerated, now 6). - daemon mint_master_cap → BackendClient::cap_mint (the cap-mint body — the #200 drift locus — is now the crate's BrokerCapRequest for memory AND config; one function covers all 4 routes). Worker put/get bodies (memory + config) build from the crate's MemoryPutBody/MemoryGetBody/ConfigPutBody/ConfigGetBody types; the raw POST stays in the daemon to reuse the once-minted STS creds across namespaces. Re-added agentkeys-provisioner to the daemon (still used for that STS mint). - gate: config_put/config_get fixtures are pass-1-annotatable but EXCLUDED from pass-2 auto-detect (key-set-identical to cred bodies → would false-positive); documented in the gate + the fixtures README. #205's bash bodies (4-key ttl-omitted cap + ambiguous cred/config worker bodies) don't trip pass-2. - docs: arch.md tree gains agentkeys-worker-config + updated backend-client note; root CLAUDE.md #203 rule updated for the 6 endpoints + config body types. Verified: cargo build + clippy -D warnings + cargo test --workspace all clean (0 failures; plant-contract + config frozen tests pass); backend + web-api drift gates + fixture --check pass under LC_ALL=C.UTF-8; bash -n clean on all touched scripts.
hanwencheng
added a commit
that referenced
this pull request
Jun 6, 2026
…6 (web-parity)
harness-ci.yml ran v2-stage{1,2,3}-demo.sh in isolation — it predated the #200
v2-demo restructure and never picked up phase 4 (memory-plant) or phase 6
(web-parity). Phase 6 is the ONLY runtime proof of the daemon's web endpoint
(POST /v1/master/memory/plant → cap-mint → STS → worker → S3, the parent-control
app's path); stage 3 only exercises the CLI/curl path. The #203
check-web-api-drift.sh gate covers its SHAPE at compile/fixture time, but nothing
covered its runtime reachability in CI.
Switch the harness-e2e job to the whole orchestrator: `v2-demo.sh --ci` → phases
1-4 + 6. Phase 5/wire auto-skips — the §10.2 agent needs the aiosandbox, which CI
doesn't have, so --ci sets --wire none (the one phase CI genuinely can't run).
Running phases in sequence also means phase 1 registers the master that phase 6
reuses.
Enabler: v2-stage1-demo.sh now auto-skips deploy/email/provision under --ci/$CI
(CI runs against pre-provisioned infra — contracts pinned in TEST_*_HEIMA secrets,
identity via wallet_sig, vault/memory buckets+roles an operator one-shot the CI
role can't recreate). Mirrors stage-1's existing auto-WEBAUTHN-off + stage-2's
auto-stub under --ci, so `v2-demo.sh --ci` drives stage 1 without re-passing the
three skip flags. The build step now builds what v2-demo's preflight expects
(cli + daemon + mcp-server; mock-server is mock-mode-only and unused in real CI).
Docs: harness-ci.yml header + harness/CLAUDE.md CI-role note + the operator
runbook's On-CI semantics. (The runbook already documented `v2-demo.sh --ci` as
the CI front door — this makes the workflow match it.)
NOTE: the harness-e2e job is secret-gated (TEST_OIDC_AWS_ROLE_ARN) and can't run
locally — validated by YAML lint + bash -n + flag-threading review + the drift
gates; needs a CI run with the test secrets to confirm end-to-end.
hanwencheng
added a commit
that referenced
this pull request
Jun 6, 2026
…rker chain (#204) * refactor: #203 agentkeys-backend-client — ONE owner for the broker/worker chain The broker/worker HTTP chain was hand-coded in three places (MCP HttpBackend, daemon ui_bridge, harness bash), the structural cause of the #200 drift bugs (evm_address vs {address,chain_id}, bare-vs-0x omni, per-namespace field shapes). Collapse it behind one crate so drift is a COMPILE error (Rust callers share the types) or a FIXTURE mismatch (the harness gate), not a runtime 4xx. New crate agentkeys-backend-client (the dual of broker-server / worker-*): - protocol.rs: every cap-mint / worker / audit wire shape, the memory:<ns> service builder, and the 0x-omni normalizer (the daemon's old inline bug site) - client.rs: BackendClient — cap-mint (4 data-class endpoints) -> STS relay -> worker put/get -> audit append (the reference impl lifted out of HttpBackend) - fixtures.rs + dump-protocol-fixtures bin: canonical fixtures serialized from the serde types + frozen key-set pins Collapse the duplicates (net -355 LOC in existing files): - MCP HttpBackend -> thin delegate over BackendClient; backend wire-shape submodules (broker/memory/audit) deleted, re-exported from the crate so the Backend trait + InMemoryBackend + tools keep their crate::backend::* paths - daemon memory_put_real / real_memory_ctx -> call the shared client (kills the duplicate cap-mint body + the inline 0x-normalize where the bugs lived) Enforce (fold-systemic-fixes-into-enforcement): - scripts/check-backend-fixture-drift.sh: diffs every # @backend-fixture- annotated bash body against the crate-emitted fixtures (catches add/rename/drop) - harness-ci.yml rust-checks runs the fixture --check + the bash gate on every PR touching crates/**, harness/**, scripts/** - root CLAUDE.md + harness/CLAUDE.md "broker/worker shapes have ONE owner" rule; arch.md component inventory updated * refactor: #203 tier-2 — close phase-6's frontend false-green (the #206 parity ladder) #204 made the broker/worker chain tier-3 (compile-enforced). The adjacent blind spot the #206 ladder names is the daemon's web API: the route /v1/master/memory/plant + the ApiMemoryEntry body are hand-copied in 3 places — the daemon (Rust source of truth), the React frontend daemon.ts, and the harness web-parity-demo.sh — agreeing only by manual coincidence. A daemon.ts route/shape change left phase 6 green on the old path (false-green). Pin all three to one serde source of truth (rung 2 of the ladder): - daemon: MASTER_MEMORY_{,PLANT_}ROUTE consts (used by the router) + a ui_bridge unit test (master_memory_plant_contract_matches_fixture) pinning ApiMemoryEntry's keys + the route to harness/fixtures/web-api/master_memory_plant.json - gate scripts/check-web-api-drift.sh diffs the two NON-Rust consumers (daemon.ts + web-parity-demo.sh, both carrying a `@web-fixture: master_memory_plant` annotation) against that fixture — route + entry key-set. Wired into harness-ci rust-checks. - a daemon.ts route rename or entry field add/rename/drop is now CI-red, not a stale green (negative-tested both halves). Docs: update the #206 ladder section in harness/CLAUDE.md (false-green now CLOSED; plant contract is at rung 2; rung-3 endgame = agentkeys-web-core wasm so daemon.ts stops hand-building the body); add the web-api gate to the root CLAUDE.md #203 rule. * fix(harness-ci): brace $SCAN_DIR so the fixture gate survives set -u in a UTF-8 locale scripts/check-backend-fixture-drift.sh interpolated `$SCAN_DIR…` — the variable immediately followed by a Unicode ellipsis (U+2026, E2 80 A6). Under `set -u` in a UTF-8 locale (C.UTF-8 / en_US.UTF-8 — what GitHub ubuntu-latest uses), bash's multibyte identifier scan absorbs the ellipsis into the name, reads `SCAN_DIR…` as an unbound variable, and aborts before checking any fixture. The new harness-ci `rust-checks` step (`bash scripts/check-backend-fixture-drift.sh`) would then fail on EVERY PR regardless of protocol correctness, and the drift protection never ran. Reproduced locally: `set -u; V=/tmp; echo "$V…"` exits 1 (`V: unbound variable`) under LC_ALL=C.UTF-8/en_US.UTF-8 but exits 0 under LC_ALL=C; the braced form `${V}...` exits 0 under all three. Both gates now pass under LC_ALL=C.UTF-8 + en_US.UTF-8. Fix: brace the var (`${SCAN_DIR}`, the CLAUDE.md interpolation-defense convention) and use ASCII `...` so no following byte can extend the name. Also switched the one other executable ellipsis log line in check-web-api-drift.sh to ASCII for the same robustness. Repo-wide scan confirms no other `$VAR<multibyte>` adjacency in scripts/ or harness/. (Codex adversarial-review finding.) * fix(harness-ci): tighten both drift gates — call-site route check + unannotated-canonical guard Two Codex adversarial-review findings, both a residual false-green: 1. Route check passed on stale literals (check-web-api-drift.sh). The web-api gate `grep`ed the whole consumer file for the canonical route, so the route appearing in a step label / comment satisfied it even if the actual POST URL changed — the exact false-green the gate exists to close. Now assert the CALL SITE: the route must appear immediately followed by a closing quote (it terminates a URL/string literal) within a few lines of a `curl`/`-X POST` (bash) or `postJson`/`fetch` (TS) call. A stale label (route followed by a space/arrow) no longer satisfies it; a drifted prefix like `…/plantX"` is rejected because the char after `plant` is `X`, not a quote. Verified: changing the real curl URL while leaving the step label stale now fails. 2. Fixture gate missed an unannotated canonical body (memory-plant-demo.sh:154). The `/v1/memory/get` read-back hand-rolled `{cap, namespace}` with no `@backend-fixture` annotation, so pass 1 (annotated-only) never gated it. Fix both ways: (a) annotate that body; (b) add pass 2 to check-backend-fixture-drift.sh — scan EVERY single-quoted jq object literal and fail any whose key-set EXACTLY matches a canonical fixture but lacks an annotation. Exact-match is false-positive-free: the v2-stage3 cred bodies (`{cap, plaintext_b64}`, `{cap}`) and the ttl-omitted 4-key cap variant (broker `CapRequest.ttl_seconds` is `#[serde(default)]`) match no canonical set and are left alone. Verified: removing the annotation now fails pass 2; re-adding passes; no other unannotated canonical bodies exist in the harness. Both gates pass under LC_ALL=C.UTF-8 + en_US.UTF-8; bash -n clean. (Codex adversarial-review findings.) * ci(harness): run the whole v2-demo (--ci) so harness-CI covers phase 6 (web-parity) harness-ci.yml ran v2-stage{1,2,3}-demo.sh in isolation — it predated the #200 v2-demo restructure and never picked up phase 4 (memory-plant) or phase 6 (web-parity). Phase 6 is the ONLY runtime proof of the daemon's web endpoint (POST /v1/master/memory/plant → cap-mint → STS → worker → S3, the parent-control app's path); stage 3 only exercises the CLI/curl path. The #203 check-web-api-drift.sh gate covers its SHAPE at compile/fixture time, but nothing covered its runtime reachability in CI. Switch the harness-e2e job to the whole orchestrator: `v2-demo.sh --ci` → phases 1-4 + 6. Phase 5/wire auto-skips — the §10.2 agent needs the aiosandbox, which CI doesn't have, so --ci sets --wire none (the one phase CI genuinely can't run). Running phases in sequence also means phase 1 registers the master that phase 6 reuses. Enabler: v2-stage1-demo.sh now auto-skips deploy/email/provision under --ci/$CI (CI runs against pre-provisioned infra — contracts pinned in TEST_*_HEIMA secrets, identity via wallet_sig, vault/memory buckets+roles an operator one-shot the CI role can't recreate). Mirrors stage-1's existing auto-WEBAUTHN-off + stage-2's auto-stub under --ci, so `v2-demo.sh --ci` drives stage 1 without re-passing the three skip flags. The build step now builds what v2-demo's preflight expects (cli + daemon + mcp-server; mock-server is mock-mode-only and unused in real CI). Docs: harness-ci.yml header + harness/CLAUDE.md CI-role note + the operator runbook's On-CI semantics. (The runbook already documented `v2-demo.sh --ci` as the CI front door — this makes the workflow match it.) NOTE: the harness-e2e job is secret-gated (TEST_OIDC_AWS_ROLE_ARN) and can't run locally — validated by YAML lint + bash -n + flag-threading review + the drift gates; needs a CI run with the test secrets to confirm end-to-end. * fix(harness): v2-demo preflight must put target/release on PATH (CI phase-1 fix) The harness-CI switch to `v2-demo.sh --ci` failed at phase 1: `v2-stage1-demo.sh: line 360: agentkeys: command not found`. The stages call a BARE `agentkeys` (resolved from PATH). In the old per-stage CI, each stage ran its own build step (no --skip-build), which installs agentkeys onto PATH via install-agentkeys-cli.sh. Under v2-demo the preflight builds target/release ONCE and tells the stages to --skip-build — so they skip the install, and the preflight never exposed the built binary on PATH. CI has no globally-installed agentkeys, so the bare call died, cascading: phase 1 died at step 5 (before its register), so the master was never registered → phase 2 register_first_master also couldn't find agentkeys-cli → heima-worker-smoke failed. Fix: the preflight now `export PATH="$PROJECT_ROOT/target/release:$PATH"` right after the build — so every phase subprocess resolves the just-built agentkeys / agentkeys-daemon (prepended, so it wins over any stale global install). This is the missing piece of the preflight's "build once, phases reuse" contract; it helps operators too (they get the build they just made, not a stale install). Verified locally: a bare `agentkeys chain show heima` resolves + runs under the exported PATH (the exact line-360 pattern). Also (cleanup completeness): the harness-e2e S3 cleanup now also wipes the CONFIG bucket's bots/<omni>/config/ (phase 6 writes the #201 memory-taxonomy there when config infra is present). Guarded by [ -n "$CONFIG_BUCKET" ], so it's a no-op until TEST_CONFIG_BUCKET is set. Memory + creds (vault/memory buckets) were already wiped; this closes the config-class gap.
This was referenced Jun 6, 2026
Merged
hanwencheng
added a commit
that referenced
this pull request
Jun 7, 2026
Ties the two existing halves into one ready-to-sign PackedUserOperation: - intent: agentkeys_core::erc4337::accept_batch_calldata (the atomic executeBatch([registerAgentDevice, setScope]), P.2+P.3) - sponsorship: the broker EIP-191 co-signs the VerifyingPaymaster getHash (J1-gated Sybil gate = gas-free), via crate::sponsor (#200 Stage A). New crates/agentkeys-broker-server/src/sponsored_accept.rs: - AcceptUserOpParams — every chain-derived value (nonce/gas/fees/validity/addrs) is an explicit input (nothing hardcoded; caller reads them on-chain). - assemble_accept_userop(params, broker_sk) -> AssembledAcceptUserOp { user_op, user_op_hash, paymaster_get_hash }. Sets paymasterAndData[20:52] (the gas word) provisionally so paymaster_get_hash commits the limits the broker approves, then rebuilds paymasterAndData with the real co-sign appended; computes the userOpHash the master K11 signs. Pure (broker key only, no chain I/O). Broker-side because the paymaster co-sign needs the broker key; the daemon will call this via an endpoint and just K11-sign the returned userOpHash (the #200 division of labour). 3 unit tests: callData==accept batch + sender==master + empty account sig + deterministic hash; paymasterAndData layout + broker co-sign recovers to the broker EOA; grant change => userOpHash change. cargo test + clippy green. Slice 2 of #225. Next: the broker HTTP endpoint wrapping this + the daemon call + the Stage-B handleOps submit (cast-based, mirrors the E8 proof). Refs #225.
hanwencheng
added a commit
that referenced
this pull request
Jun 8, 2026
…point-of-compromise (#223) * feat: #76 cap-mint K10 proof-of-possession — close the broker single-point-of-compromise Every cap-mint now carries a K10 device-key signature the worker re-verifies INDEPENDENTLY of the broker, so a compromised broker (which holds no K10 private key) cannot mint a usable cap. Closes the §22b.4 stage-1 gap, where the worker re-checked only the broker's own broker_sig + on-chain device *registration* (device_key_hash is a public identifier), never possession. - core: device_crypto::cap_pop_payload (domain-separated, request-bound) + cap_pop_now/cap_pop_sig + load_device_key_from_env - broker: handlers/cap.rs::verify_cap_pop rejects forged/missing/stale client_sig (cap_pop_invalid 4xx); §22b.4 shortcut removed - workers (cred/memory/config/classify): verify::check_client_pop, fail-closed, gated by AGENTKEYS_WORKER_REQUIRE_CAP_POP (default enforce, mirrors REQUIRE_STS) - clients: BackendClient::with_device_key signs the PoP inside cap_mint (MCP, daemon ui-bridge, proxy); BrokerCapRequest fields + #203 fixtures regenerated - master K10/K11 split: harness/scripts/heima-register-master-k10.sh registers the master's secp256k1 K10 as a CAP_MINT device (registerAdditionalMasterDevice, reusing the #200/#164 K11-assertion machinery), wired into setup-heima step 15 - docs: arch.md §22b.4 resolved + headline guarantee; CLAUDE.md isolation table Agent path verified: 50 Rust test suites green, clippy clean, backend-fixture gate green. NEEDS-LIVE-VERIFICATION (no chain / Touch ID here): the on-chain master-K10 registration (cast/ABI + EOA-vs-#164-UserOp msg.sender) and cast EIP-191 matching device_crypto::eip191_sign in the 2 master-path harness demos. * style: cargo fmt the #76 K10 PoP code (CI fmt --check) * fix: #76 make cap-mint K10 PoP optional + graceful (staged rollout) The harness on test infra caught that hard-requiring the K10 cap-PoP broke the master-self path: the master registers device_key_hash=keccak(operator_omni) (the #164 passkey account) and has no secp256k1 K10 registered yet, so master cap-mints (phase 4 memory-plant, phase 6 web-parity) failed 'master K10 not found'. Make the PoP optional + verify-when-present (the correct non-breaking staged rollout): - protocol/broker/worker: client_sig/nonce/ts are Option; a supplied PoP is always validated (broker verify_cap_pop + worker), a MISSING PoP is rejected ONLY under AGENTKEYS_WORKER_REQUIRE_CAP_POP=1 (default OFF). New verify::enforce_client_pop centralizes the gate across the 4 workers. - clients (BackendClient/ui_bridge/proxy): sign when a K10 is available, else mint with no PoP + the caller's device_key_hash — no hard-fail. - harness master demos: revert to no-PoP bodies (master mints without PoP until its K10 is registered); fixture cap_mint_request back to the minimal no-PoP key-set. - docs (arch §22b.4 + headline, CLAUDE.md): enforcement is a staged flag-flip after every actor's K10 (incl. the master's) is registered — that's when the SPOF closes. The agent path still carries a verified PoP (agents register keccak(K10 addr)). fmt + clippy + full test suite + fixture gate green locally. * fix(harness): tolerate email-inbox 5xx (502/503), not just 500, in worker-smoke The funded harness run cleared the gas failures; the only remaining red was phase 1 step 15 (worker-smoke email-inbox) returning HTTP 502 — the SAME known 's3:ListBucket IAM not wired on the broker EC2' follow-up the soft-warn already tolerates as 500, but surfacing via nginx (502) when the worker errors on ListObjects. The toleration only matched 500, so the 502 variant fell through to die. Broaden to the 5xx class (500|502|503). Not a #76/code issue — the email worker /healthz passes; inbox LIST IAM is a separate deploy follow-up. All code gates + harness phases 2-6 (incl. the #76 cap-PoP path: phase 3 negatives, phase 4 plant, phase 6 web-parity) already pass on the funded run.
hanwencheng
added a commit
that referenced
this pull request
Jun 9, 2026
….3) (#227) * feat: #216 agent cred-fetch — CLI consumer + real e2e (VERIFIED against live infra) The agent-facing consumer of the #216 cred-fetch primitive, verified end-to-end against the LIVE broker + cred worker: - agentkeys-cli: `agentkeys cred fetch <service>` (cred_admin.rs) — mints a master-self/agent CredFetch cap → BackendClient.cred_fetch → STS → cred worker → decrypt → prints the plaintext. Adds the agentkeys-backend-client dep (the #204 one-owner path; no re-typed wire shapes). - harness/cred-fetch-demo.sh — the real e2e: a master VAULTS a probe cred via the daemon (web path), then the agent FETCHES it via the CLI (agent path), asserting the EXACT secret round-trips through cap-mint → STS → cred worker → S3 → decrypt. Idempotent (fixed `cred-e2e-probe`), --ci-tolerant, real-only. Contract-compliant (STEP_TOTAL=4, ok/skip/fail, EXIT-trap daemon cleanup). - keep-docs-in-sync: harness/CLAUDE.md orchestrator table + operator-runbook-harness.md. VERIFIED LIVE (this run): master vaulted via daemon (HTTP 200), agent `cred fetch` returned the EXACT key (len matched) — broker.litentry.org + cred.litentry.org. #216's cred half is proven, not just compiled. Remaining #216: the Hermes wire (phase1-wire Phase 4.0) — plant the fetched key into Hermes instead of $OPENROUTER_API_KEY (the full sandbox surprise). * feat: #216 cred-wire-demo.sh — the FULL agent-side wire e2e (VERIFIED live, real LLM) Carries the #216 cred-fetch through the Hermes wire — the complete agent-side guarantee, proven end-to-end against the LIVE broker + cred worker + aiosandbox: master VAULTS the LLM key (daemon: cap-mint cred-store → STS → cred worker → S3) → agent CRED-FETCHES it (agentkeys cred fetch: cap-mint cred-fetch → STS → decrypt) → plant into Hermes (~/.hermes/.env + hermes config set model.*) IN THE SANDBOX → Hermes RUNS on the vault key (real LLM smoke) — NO OPENROUTER_API_KEY in the agent env harness/cred-wire-demo.sh (STEP_TOTAL=6, contract-compliant, headless): asserts the key Hermes uses == the master-vaulted key (sha), and that it arrived via the vault fetch, not an ambient env var (the sandbox shell has no OPENROUTER_API_KEY; the .env value is the cred-fetch result). The durable, no-Touch-ID complement to phase1-wire-demo.sh Phase 4.0b — same wire result without the interactive gates. Routes through the shared agentkeys-backend-client (#204). VERIFIED LIVE (this run, real OpenRouter key): step 4 ok agent fetched the vaulted key from the vault (len=73, sha fddff3ff…) — no env read step 5 ok planted the vault-fetched key into ~/.hermes/.env + hermes config step 6 ok 6.1 vault-sourced — the key Hermes will use == the master-vaulted key, NOT an env var step 6 ok 6.2 llm smoke — Hermes answered using the VAULT-FETCHED key: "OK" Exit 0. A REAL deepseek-v4-flash call via OpenRouter answered "OK" on the vault-fetched key — #216's acceptance ("the agent runs on MY authorized key, not the operator's env") proven with real data. Idempotent (FIXED openrouter service; the .env key-line is rewritten not appended); daemon killed on exit; --ci-tolerant. keep-docs-in-sync: harness/CLAUDE.md + docs/operator-runbook-harness.md. * feat: #216 phase1-wire Phase 4.0b — plant the VAULT-fetched key (env → dev fallback) Replaces the operator-env-key write (#216's named target: phase1-wire-demo.sh:1072) with the vault path: Phase 4.0b now fetches the agent's LLM key from the master's VAULT via `agentkeys cred fetch cred:<service>` and plants THAT into the sandbox Hermes — the $OPENROUTER_API_KEY/$LLM_API_KEY env becomes a clearly-labelled DEV-ONLY fallback. - Phase 4.0b resolves WIRE_KEY VAULT-FIRST (the agent-identity cred-fetch: operator session authorizes, actor=agent device — mirrors the memory cap-mint identity model), env-fallback only when the vault is unavailable. Backward-compatible: with no vaulted key / no cred scope the fetch fails and it degrades to the env key exactly as before, so the change is fallback-safe. - SEED_SCOPE_SERVICES also grants the agent its cred scope (bare `$SERVICE` — the cred-fetch cap-mint hashes the bare service, unlike memory's `memory:<ns>`) so the P.3 pairing grant authorizes the vault fetch. - Honest labelling throughout: the 0.6 step, the header, and the top overview now state the env key is the dev fallback and the vault is primary; the 4.0 ok line prints which source the planted key came from. The full vault chain (master vaults → agent cred-fetches → plant → Hermes runs on it, real LLM smoke) is proven headless + live by harness/cred-wire-demo.sh (this PR). The interactive agent-identity path additionally needs the operator's Touch ID cred-scope grant (P.3) + a seeded vault — until then Phase 4.0b labels + uses the dev fallback. * feat: #216 `agentkeys cred store` — symmetric store half + #204 daemon fix (verified live) Completes the CLI cred surface with the store half of `cred fetch`, and folds the daemon's hand-rolled cred-store body into the crate (closing a #204 drift gap): - agentkeys-backend-client: `CredStoreBody`/`CredStoreResp`/`CredStoreInput`/ `CredStoreResult` (mirror the CredFetch types) + `BackendClient::cred_store` (cap-mint CredStore → per-actor STS under the VAULT role → cred worker `/v1/cred/store` → encrypt + S3 PUT). Exported from the crate. - agentkeys-daemon: `store_master_credential_inner` now builds the worker body from the crate-owned `CredStoreBody` instead of an inline `serde_json::json!({...})` (#204 — "broker/worker request shapes have ONE owner"; a drifted field is now a compile error, matching the memory-put path). - agentkeys-cli: `agentkeys cred store <service> --secret|--secret-env` (master-self by default). `--secret-env NAME` keeps the plaintext off argv / out of the shell history + process list. Prints the worker S3 key. VERIFIED LIVE (CLI-only store→fetch round-trip, master-self): stored `cred-store-probe` → bots/941…/credentials/cred-store-probe.enc ✅ CLI store→fetch ROUND-TRIP PASS — agentkeys cred store works end-to-end Scope note: this is the master-self vault primitive. The master provisioning a key INTO the agent's S3 prefix (so the agent fetches with actor=agent) needs dual bearers (operator session for cap-mint + agent session for the STS PrincipalTag) and is #214's authorization-side job — deliberately out of #216 scope. clippy -D warnings clean; cargo check green. * docs: #216 make operator-runbook-wire.md the single source of truth (web app + CLI, fresh start) Restructures the wire runbook from a CLI/sandbox + memory-only "run the demo" doc into the single fresh-start guide for testing the WHOLE wire — both the #216 vault-fetched LLM key and the permissioned memory — two ways: - New top: the two guarantees, a two-paths table (web app vs CLI, same agent side), the fastest test (`harness/cred-wire-demo.sh`), and a fresh-start checklist (3 setup scripts + sandbox + OpenRouter key + master identity). - Path A — Web app: `bash dev.sh` → onboard → vault the key (credentials page) → pair+authorize (pairing page, Touch ID). Honest "wired vs pending" note: the web vault + #214 pairing are real/on-chain today; the agent-identity vault-fetch needs #214's dual-bearer master-provisioning (not wired yet), so the master-self cred-wire-demo is the end-to-end proof. - Path B — CLI: the existing phase1-wire-demo walkthrough, reframed. - LLM-key gate now documents Phase 4.0b vault-first/env-fallback; "Verifying it worked" splits into the two deterministic checks; +3 web/cred troubleshooting rows; Appendix B gains the `cred store`/`cred fetch` primitives; cross-refs add the new demos + #216/#214 + dev.sh. keep-docs-in-sync: folds back the cred-wire-demo + cred-store + Phase 4.0b changes from this PR into the operator runbook. * docs: #216 fix Path A — the web app doesn't provision the agent device Caught in review: Path A had the agent run in the sandbox (agentkeys-daemon --request-pairing → cred fetch → wire hermes) but never said how the compiled agentkeys / agentkeys-daemon / agentkeys-mcp-server binaries get INTO the sandbox. They can't run there unless cross-built for the sandbox's Linux arch and uploaded (the sandbox is aarch64/x86 Linux, not the operator's Mac) — which is what Path B / phase1-wire-demo.sh Phase 1 does (target/sandbox-linux cross-build → sbx_put). Rewrote Path A to be honest: - The web app is ONLY the master's console; it does not provision the agent device. - A. Vault the LLM key — fully standalone (no sandbox). - B. Pair — needs the agent binaries in the sandbox first; and phase1-wire's Phase 1 bundles the cross-build/upload WITH the CLI pairing (Phase P lives inside Phase 1), so there's no clean "binaries only" command and no one-command web-pairing flow yet (drive the web claim by hand: upload binaries, open a request, claim in the UI). - C. End-to-end is the headless cred-wire-demo.sh / Path B. Also corrected my own first attempt, which suggested `--skip-2..5` to "stage only the sandbox" — that still runs Phase 1 and therefore CLI-pairs the agent. * docs+harness: #216 make wire runbook Path A / Path B fully independent + add sandbox-build-push.sh Per review: the runbook treated Path A as leaning on Path B's harness for the agent side. Now each path is a self-contained quick-start. - NEW harness/sandbox-build-push.sh — Path A's standalone "compile agentkeys + push to the sandbox" command. Cross-builds the 3 binaries (agentkeys / -mcp-server / -daemon) for the sandbox's aarch64-Linux arch in the SAME cached arm64 builder image + cargo volumes phase1-wire-demo uses (warm tree re-pushes in seconds), uploads them to ~/.local/bin. Build + push ONLY — never pairs/wires. Re-run after any local change so the in-sandbox agent runs current source. VERIFIED live: pushed to the sandbox, and `agentkeys cred --help` there confirms the current #216 source. - operator-runbook-wire.md restructured: "Two independent paths — pick one" with BRIEF quick-starts for each (Path A = sandbox-build-push.sh + dev.sh + 3 UI actions; Path B = one phase1-wire-demo command) + a "neither path" headless check (cred-wire-demo). Path A details now use sandbox-build-push.sh (dropped the phase1-wire dependence + the now-moot "harness bundles pairing" caveat); kept the honest #214 wired-vs-pending note. - keep-docs-in-sync: harness/CLAUDE.md inventory + operator-runbook-harness.md. * docs: #216 fix Path A pairing command — --request-pairing requires --broker-url Operator hit `Error: --broker-url (or AGENTKEYS_BROKER_URL) required for --request-pairing` running the runbook command in the sandbox — my Path A command dropped the required flag. Verified the corrected invocation in the live sandbox (produces a pairing_code). Folded the complete, correct flow into Path A: 1. sandbox: agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org → prints pairing_code + a state_file (the request_id lives in the file, not stdout) 2. web UI: claim the pairing_code (Touch ID) 3. sandbox: agentkeys-daemon --retrieve-pairing --request-id <from state file> --broker-url … Matches phase1-wire-demo.sh Phase P.0/P.1b exactly. Fixed both the quick-start and the Path A — details command. * feat: #216 default the agent pairing broker to prod (no --broker-url needed) `agentkeys-daemon --request-pairing` / `--retrieve-pairing` required --broker-url (or AGENTKEYS_BROKER_URL) and errored without it — friction for the Path-A operator running them in the sandbox. These commands ALWAYS need a broker, so default it: - main.rs: new `const DEFAULT_PAIRING_BROKER_URL = "https://broker.litentry.org"`; run_request_pairing + run_retrieve_pairing now `unwrap_or_else(default)` instead of erroring. `--broker-url` / `AGENTKEYS_BROKER_URL` still override (e.g. a test broker). Deliberately NOT a global arg default — `--ui-bridge`'s unset broker_url keeps its "fall back to pre-sourced AWS creds" meaning (the §191 pre-Stage-7 path). VERIFIED live: cross-built + pushed the daemon to the sandbox; `agentkeys-daemon --request-pairing` (no flag) now defaults to prod + opens a §10.2 request (code 9ZpC8nwu…) — the "--broker-url required" error is gone. Runbook (Path A quick-start + details) simplified to drop the flag; notes the prod default + the override. clippy -D warnings clean; daemon tests green. * fix: #214 web pairing register 502 — daemon couldn't find heima-agent-create.sh `accept pairing · Touch ID` POSTed /v1/agent/pairing/register and got 502. Root cause: register_pairing derived the agent-register script as a SIBLING of --register-master-script, but the two are NOT co-located — dev.sh's master register is harness/scripts/heima-register-first-master.sh while heima-agent-create.sh lives in <repo>/scripts/. The sibling path (harness/scripts/heima-agent-create.sh) doesn't exist, so `bash <missing>` exited non-zero → register_agent_device errored → 502. Fix: resolve heima-agent-create.sh from candidates — the sibling (co-located case) AND <repo>/scripts/ derived from the master script path — picking the first that exists; fail with a clear SERVICE_UNAVAILABLE message if neither is found. Verified: scripts/heima-agent-create.sh accepts exactly the args register_agent_device passes (--label/--agent-address/--actor-omni/--device-key-hash/--pop-sig, from-pubkey mode auto-detected), and a dry-run with the live agent details returns {"ok":true,"skipped":"already-registered"} → register_agent_device → Ok(None) → 200. The "no Touch ID" is expected (browser passkey UserOp is the E7-pending frontend item; the register goes through the daemon script shell-out today). clippy -D warnings clean; daemon tests green. * style: rustfmt the merged ui_bridge.rs (register path-fix block) * feat: #224 pairing-card cross-verification — show device_key_hash + full request_id (slice 1) The master pairing card showed a truncated "PAIR-CODE" that was actually the request_id (never the agent's one-time code), with no value the operator could cross-check against the agent — a confused-deputy surface (#224). Slice 1 surfaces the values that ARE on both sides today, with no broker change/deploy: - daemon (pending_binding_to_request): map the broker's device_key_hash → `deviceKeyHash` (+ short); keep `id` (the full request_id). The agent's `--request-pairing` already prints device_key_hash + D_pub, so these are the cross-verifiable identity. - agent (run_request_pairing): print device_key_hash on the human-facing line so the operator reads it off the agent to compare. - frontend (PairingRequest type + pairing card): replace the misleading "pair-code" with **device key hash · verify on agent** + **D_pub · verify on agent** (full) + **request id** (full handle). Operator confirms the device matches before accept · Touch ID. - test: pending_binding_maps_to_pairing_request asserts the full deviceKeyHash. Deferred to slice 2 (needs a broker change + deploy): created_at/expires_at timestamps on the card (the broker pending row has no timestamps today) and the `--force` supersede-prior-requests behavior. clippy/fmt clean; daemon tests + frontend typecheck green. * ui: #224 relabel pairing card D_pub → 'device public address · verify on agent' * ui: refresh paired-device list after accept so it shows without a manual reload acceptPairing did registerPairing + refreshPairing but never re-fetched the actor tree, so a freshly-registered agent only appeared in the device/permission views after the operator reloaded the page. Re-fetch listActors after a successful register (matches finishPairingCeremony), surfacing the paired device immediately. * feat: #225 E7 — ERC-4337 accept-batch callData builders (atomic P.2+P.3) The agent-accept gate (#225 / #164 E7) lands the device binding (registerAgentDevice, P.2) and the scope grant (setScope, P.3) in ONE P256Account.executeBatch UserOp — one block, one K11 signature, atomic. This adds the pure callData encoders that the batch needs (the genuinely new primitive); the sponsored-UserOp envelope is already owned by the broker's sponsor.rs (#200 Stage A). New crates/agentkeys-core/src/erc4337.rs: - register_agent_device_calldata — registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes) - set_scope_calldata — setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32) - execute_batch_calldata — executeBatch(address[],uint256[],bytes[]) - accept_batch_calldata — the headline: executeBatch([register, setScope]); threads the agent's actor_omni into both inner calls so they can't disagree on which agent they bind. Hand-rolled ABI (no alloy/ethabi — matches sponsor.rs/audit::calldata style), reusing the public audit::calldata::selector so selectors never drift. Golden-tested byte-for-byte against foundry cast for all three: cast calldata "registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)" ... cast calldata "setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)" ... cast calldata "executeBatch(address[],uint256[],bytes[])" "[reg,scope]" "[0,0]" "[reg_cd,scope_cd]" fixtures committed under src/testdata/. cargo test + clippy green. First slice of #225; the submission client (#200 Stage B), the daemon wiring, the browser ceremony, and the on-chain cutover remain (tracked in #225). * feat: #225 E7 — assemble the sponsored accept UserOp (broker composer) Ties the two existing halves into one ready-to-sign PackedUserOperation: - intent: agentkeys_core::erc4337::accept_batch_calldata (the atomic executeBatch([registerAgentDevice, setScope]), P.2+P.3) - sponsorship: the broker EIP-191 co-signs the VerifyingPaymaster getHash (J1-gated Sybil gate = gas-free), via crate::sponsor (#200 Stage A). New crates/agentkeys-broker-server/src/sponsored_accept.rs: - AcceptUserOpParams — every chain-derived value (nonce/gas/fees/validity/addrs) is an explicit input (nothing hardcoded; caller reads them on-chain). - assemble_accept_userop(params, broker_sk) -> AssembledAcceptUserOp { user_op, user_op_hash, paymaster_get_hash }. Sets paymasterAndData[20:52] (the gas word) provisionally so paymaster_get_hash commits the limits the broker approves, then rebuilds paymasterAndData with the real co-sign appended; computes the userOpHash the master K11 signs. Pure (broker key only, no chain I/O). Broker-side because the paymaster co-sign needs the broker key; the daemon will call this via an endpoint and just K11-sign the returned userOpHash (the #200 division of labour). 3 unit tests: callData==accept batch + sender==master + empty account sig + deterministic hash; paymasterAndData layout + broker co-sign recovers to the broker EOA; grant change => userOpHash change. cargo test + clippy green. Slice 2 of #225. Next: the broker HTTP endpoint wrapping this + the daemon call + the Stage-B handleOps submit (cast-based, mirrors the E8 proof). Refs #225. * feat: #225 E7 — accept-flow wire types (backend-client, #204 one-owner) Defines the daemon<->broker protocol for the on-chain K11-gated accept, in the ONE owner crate per the #204 rule (the daemon deps backend-client; the broker mirrors these shapes server-side, pinned by the frozen key-set tests): - BuildAcceptUserOpRequest — POST /v1/accept/build (J1_master): register fields (device_key_hash, agent_pop_sig, link_code_redemption) + the granted scope (services + u128 caps as wire-safe decimal strings + period_seconds). - WireUserOp — ERC-4337 v0.7 PackedUserOperation, hex per field; mirrors broker sponsor::PackedUserOp. The daemon fills with the master K11 assertion over user_op_hash. - BuildAcceptUserOpResponse — { user_op, user_op_hash, entry_point, chain_id }. - SubmitAcceptUserOpRequest / SubmitAcceptUserOpResponse — POST /v1/accept/submit → EntryPoint.handleOps (Stage B), returns { ok, tx_hash, block_number }. Fixtures regenerated via dump-protocol-fixtures + frozen key-set tests for the three request bodies (build_accept_userop_request, wire_user_op, submit_accept_userop_request). cargo test + clippy + fixture --check green. Slice 3 of #225. Next: the broker /v1/accept/{build,submit} handlers (mirror these shapes server-side, gate on J1, call assemble_accept_userop) + the daemon call + K11-sign. Refs #225. * feat: #225 E7 — PackedUserOp→wire conversion + /v1/accept/build response The connective piece the broker accept handler returns: convert the internal sponsor::PackedUserOp into the hex-encoded wire shape and shape the build body. crates/agentkeys-broker-server/src/sponsored_accept.rs: - WireUserOp — broker-side mirror of backend_client::protocol::WireUserOp (the broker doesn't dep that crate; frozen key-set tests on both sides pin them). - WireUserOp::from_packed — hex-0x each PackedUserOp field. - BuildAcceptResponse + AssembledAcceptUserOp::into_build_response — the /v1/accept/build body { user_op, user_op_hash, entry_point, chain_id }. 3 unit tests: every wire field round-trips back to the original bytes; the build response carries the accept-batch callData + the userOpHash + entry_point/chain_id; WireUserOp JSON keys match the backend-client frozen shape (server-side #204 pin). cargo test + clippy green. Slice 4 of #225. Next (the I/O layer, happy-path gated on a deployed P256Account master): the axum /v1/accept/{build,submit} routes — J1_master auth (mirror mint_cap) + eth_call operatorMasterWallet/getNonce + assemble_accept_userop + into_build_response; submit relays EntryPoint.handleOps. Refs #225. * docs: #225 E7 — scope the account-auth cutover + onboarding-as-account The precise, idempotent spec for the live-mainnet cutover that unblocks the #225 e2e (PR #227's /v1/accept flow needs the master to BE a deployed P256Account, not the current EOA). docs/plan/chain/account-auth-cutover.md specifies: - The gap: registry/scope sources are account-auth in code (E3) but the LIVE bytecode is pre-E3; heima-bring-up's cast-code idempotency check skips the redeploy, so account-auth never goes live. - The consequence (loud): DeployAgentKeysV1 redeploys to NEW addresses → all on-chain state (master, agents, scopes, epoch, audit) resets → full re-bootstrap; demo breaks until re-bootstrapped. Operator-gated, announced, NOT in the plain flow. - 6 phases (pre-flight → redeploy v2 set FORCE_DEPLOY → redeploy P256AccountFactory → onboarding-as-account → re-bootstrap actors → code/doc updates → broker redeploy), each idempotent with explicit skip checks. - Idempotency strategy for a REDEPLOY (cast-code alone is insufficient since the old contracts also have code): a CUTOVER_DONE_<profile> marker + a live setScope account-auth ABI capability probe. - The two scripts to implement (heima-cutover-account-auth.sh + heima-deploy-master-account.sh), the setup-heima.sh --cutover-account-auth wiring, the #201 env 3-file discipline, rollback (restore the .pre-cutover.bak env), and the arch.md §10/§12 + deployed-contracts.md sync owed at Phase 5. Refs #225. Scopes the cutover named in erc4337-master-account.md §3.1. * docs: #225 — cutover spec reuses erc4337-register-master.sh; decouple master-as-account Diligence correction to the cutover spec after finding the onboarding-as-account step already exists: - Phase 3 (onboarding-as-account) reuses the existing `erc4337-register-master.sh` (build+submit) — it already does factory.createAccount + EntryPoint-deposit + register-first-master-as-account, idempotently. Dropped the proposed (redundant) `heima-deploy-master-account.sh`; only ONE new script remains (the cutover orchestrator `heima-cutover-account-auth.sh`). - Decoupling finding (from that script's header): master-as-account is VIABLE on the LIVE pre-cutover registry (no EOA-only guard), so operatorMasterWallet[omni] can be the P256Account TODAY — no disruptive redeploy needed for that half. The cutover is only required for the accept batch's setScope (P.3): the live scope has setScopeWithWebauthn, not the msg.sender-gated setScope. So work can stage: register master-as-account now + exercise /v1/accept/build against it; do the registry/scope redeploy only when account-auth setScope is needed e2e. Refs #225. * feat: #225 E7 — heima-cutover-account-auth.sh (the account-auth cutover orchestrator) The one new script the cutover spec calls for. Forces a redeploy of the v2 set so the account-auth sources (E3) go live, making the #225 accept batch's setScope (P.3) real. Idempotent + safe + bash -n clean. scripts/heima-cutover-account-auth.sh: - Phase 0: pre-flight (assert local AgentKeysScope.sol is account-auth — setScope present, setScopeWithWebauthn gone) + back up the env addresses to operator-workstation.env.pre-cutover.bak (idempotent: skip if present). - Phase 1: redeploy via FORCE_DEPLOY=1 heima-bring-up.sh, then verify + set the CUTOVER_DONE_<profile> marker. DESTRUCTIVE → gated behind --yes; refuses otherwise. Idempotency ground truth is a read-only probe: the live scope bytecode carrying the setScope selector d8e9e3c6 (the marker is just the fast path). - Phase 2: factory CHECK only (E5 recover() isn't needed for accept; no reusable factory-deploy helper exists, so it doesn't blind-deploy). - Prints the follow-ups: re-register master-as-account (erc4337-register-master.sh), re-bootstrap agents/scopes, the repo edits (heima-scope-set.sh→setScope, arch.md), broker redeploy (setup-broker-host.sh --ref main). Classified as a directly-callable SURGICAL helper (the three-entry-points exemption for destructive heima-*-revoke/-rotate tools) — NOT wired into setup-heima.sh's plain flow, since a plain run must never reset on-chain state. Spec updated to match. Verified: bash -n clean; --help + unknown-arg guard work; setScope selector d8e9e3c6 confirmed against the earlier cast golden vectors. Cannot run e2e here (live mainnet redeploy). Refs #225. * fix: #225 — heima-cutover-account-auth.sh resolves RPC via agentkeys chain show The script died immediately with "no RPC" because it used a made-up resolution (AGENTKEYS_CHAIN_RPC_HTTP — a broker-runtime var — plus an invented RPC_HTTP_HEIMA fallback), neither of which operator-workstation.env carries. Diagnosis: both heima-bring-up.sh:122 and setup-heima.sh:195 resolve the chain RPC the same way — `agentkeys chain show "$CHAIN" | jq -r .rpc.http` (no RPC env key exists). Switched to that; added jq + agentkeys to the tool pre-check. Verified live: it now resolves https://rpc.heima-parachain.heima.network and runs to the destructive --yes gate. Also: back up the env to $HOME/.agentkeys/<name>.pre-cutover.bak instead of next to the git-tracked operator-workstation.env (a .bak there would surface as untracked). Verified the backup lands in ~/.agentkeys and leaves git status clean. Other assumptions re-checked against reality (correct): the SCOPE/REGISTRY/FACTORY address keys exist in operator-workstation.env; the profile suffix uses the sibling idiom tr 'a-z-' 'A-Z_'; the phase-0 guard holds (source AgentKeysScope.sol has setScope, no setScopeWithWebauthn). Refs #225. * docs: #225 — account-auth cutover operator runbook (+ correct the post-cutover re-bind path) Adds docs/operator-runbook-account-auth-cutover.md — the full 5-step operator procedure for the disruptive cutover, in the operator-runbook-*.md convention (H1, > warning blocks, ordered steps, rollback). Writing it surfaced a correctness bug in the earlier spec + the script's printed follow-ups: post-cutover, agent binding + scope grants go through ACCOUNT UserOps (the #225 accept flow), because account-auth gates registry/scope writes on msg.sender == operatorMasterWallet (the P256Account). The pre-cutover scripts do NOT work post-cutover — verified: - heima-agent-create.sh sends registerAgentDevice from the deployer EOA (≠ the account); - heima-scope-set.sh calls setScopeWithWebauthn (the assertion-in-calldata path account-auth removes; the new setScope is msg.sender-gated, no assertion param). So the runbook leads with two warnings: (1) SEQUENCING — run the cutover only AFTER the #225 accept flow is wired, else agents are stranded (you can re-register the master but not re-bind agents); (2) DESTRUCTIVE — state reset → full re-bootstrap. Corrected to match: - spec Phase 4 (re-bind = #225 accept flow, not heima-agent-create/heima-scope-set); - spec Phase 5 (drop the bogus heima-scope-set.sh setScopeWithWebauthn→setScope edit — it's a pre-cutover tool, retired post-cutover; just arch.md §10/§12 + deployed-contracts.md); - the script's printed follow-ups (point at the #225 accept flow + the new runbook). Verified: script bash -n clean; runbook H1/no-frontmatter/warnings present. Refs #225. * docs: #225 — simplify cutover runbook (dev-only: cutover = 1-3, then re-onboard) + fix step-4 command Per the "no user, only developer, register again" reality: - Reframe: nothing to migrate. The cutover proper is redeploy + verify + broker redeploy (steps 1-3); registering the master + pairing agents (4-5) is just normal onboarding on the fresh contracts, not a special re-bootstrap. Dropped the "DESTRUCTIVE / announce + schedule / state NOT migrated" alarm. - Master register is still REQUIRED (the new registry is empty → registerAgentDevice would revert OperatorNotRegistered), but it's one command, not the placeholder build/submit dance: bash harness/scripts/erc4337-register-master.sh --operator-omni 0x<omni> # auto Touch ID The old step-3 block was not executable (raw 0x<…>/<N> placeholders + a hand-wavy "K11 signs the userop_hash"). The default `register` mode auto-runs k11 webauthn-keygen + webauthn-userop-sign; the build/submit two-phase split is only for the browser web-flow. - Synced the script's printed follow-ups + the spec Phase 3 to the one-command form. Refs #225. * feat: unpair button + ON-CHAIN device revoke (was local-only) The "accept pairing" had no unpair, and the revoke that did exist (actor-detail view) only flipped LOCAL daemon state — the device stayed registered on chain. Both gaps closed: Daemon (ui_bridge.rs): - revoke_device now shells out to heima-device-revoke.sh (--agent <label>; agent-tier needs no K11; idempotent) BEFORE flipping local state — a binding isn't gone until SidecarRegistry.revokeAgentDevice says so. On-chain failure returns 502 and leaves local state untouched (no silent local-only revoke). New helpers: resolve_repo_script (mirrors register_pairing's heima-agent-create.sh resolution) + revoke_agent_device (mirrors register_agent_device). Errors loudly if --register-master-script / the revoke script is absent (chain-unconfigured). Test updated to mock the script + a make_state_with_script helper; passes. clippy clean. Frontend: - Unpair button on each paired-device card (pairing.tsx) → onUnpair → the existing K11/confirm revoke flow. - confirmAction now AWAITS client.revokeDevice + re-fetches the authoritative actor tree, instead of fire-and-forget + optimistic flip — so a failed on-chain revoke surfaces and a success reflects the real chain state. tsc clean. NOTE: this revoke is currently deployer-signed (like accept). Per the "sensitive UserOps need Touch ID" task, it joins accept on the list to be K11-gated (arch.md + the real gate, next). * docs: arch.md §10.1a — canonical list of Touch-ID-gated (sensitive) operations Per the "any sensitive UserOp must be Touch-ID-gated" requirement: formalized the inline enumeration at §10 (scope grant/revoke, device add/revoke, K10 rotation, recovery, audit-row mint, typed-data sign) into an explicit table in a new §10.1a. States the rule — every master-authority mutation is a P256Account UserOp, and every P256Account UserOp is K11-gated by validateUserOp (challenge == userOpHash) — maps each sensitive op to its on-chain call + UI trigger + gate status, and marks accept / unpair / scope-grant as ⏳ #225 (deployer-signed today, no Touch ID — the gap between the rule and the running code, being closed by E7 + the cutover). Also draws the authority-vs-usage boundary: cap-mint + worker reads/writes are J1+cap gated, NOT per-op Touch ID (re-prompting per memory read would be unusable), except high-value payments above payment_k11_threshold. Single source of truth (terminology rule) — extends the existing §10 enumeration rather than duplicating it. Refs #225. * feat: #225 E7 (2b slice 1) — broker /v1/accept request type + pure parser Starts the real Touch-ID gate (task 2b). The broker handlers/accept.rs: - BuildAcceptRequest — server-side mirror of the backend-client wire type (the /v1/accept/build body, J1_master-gated; broker doesn't dep backend-client, the frozen key-set test there pins the shape). - parse_register_and_grant — pure parse of the wire request into the typed agentkeys_core::erc4337 AgentRegister + ScopeGrant that assemble_accept_userop consumes. Service strings → bytes32 via keccak256(lowercase(service)) — the SAME hash heima-scope-set.sh writes (verified: keccak("memory:personal") golden), so a service id is byte-identical on every path. Caps as decimal strings (wire-safe). 3 unit tests (golden service-id, lowercasing, bad-hex/short/non-numeric rejection); cargo test + clippy green. Next 2b slices: the axum /v1/accept/build handler (J1 auth like mint_cap + eth_call operatorMasterWallet/getNonce + assemble_accept_userop + into_build_response), which needs new ENTRYPOINT/PAYMASTER env (3-file discipline) + the broker EVM co-sign key loaded; then /v1/accept/submit (handleOps, Stage B); the daemon accept wiring; the ceremony.tsx browser Touch ID over the userOpHash. Refs #225. * feat: #225 E7 (2b slice 2) — broker POST /v1/accept/build handler The keystone of the Touch-ID gate: J1_master-gated, assembles the sponsored executeBatch([registerAgentDevice, setScope]) UserOp and returns the userOpHash the master K11-signs. handlers/accept.rs: - build_accept_response (PURE, tested): request + chain reads (master account + nonce) + config + broker co-sign key → BuildAcceptResponse (via the slice-1 parser + sponsored_accept::assemble_accept_userop + into_build_response). - accept_build (axum): bearer + verify_session_jwt + operator_omni == session omni; load_accept_config (env: ENTRYPOINT/PAYMASTER/BROKER_SPONSOR_SIGNER_{ADDRESS,KEY}, registry/scope profile-aware, gas defaults as named consts); eth_call operatorMasterWallet (404→CONFLICT if no master) + getNonce; build_accept_response. - Route POST /v1/accept/build wired in lib.rs. 4 unit tests (parser ×3 + build_accept_response assembles the batch op: sender==master, 0x47e1da2a executeBatch callData, userOpHash present). cargo build + clippy green. Live prereqs (operator): the new ENTRYPOINT/PAYMASTER/sponsor-key env (set by setup-broker-host.sh) + a deployed P256Account master (the cutover). Next: slice 3 /v1/accept/submit (handleOps, Stage B). Refs #225. * feat: #225 E7 (2b slice 3) — broker POST /v1/accept/submit (handleOps, Stage B) Relays the K11-signed accept UserOp to EntryPoint.handleOps — the broker is sponsor + submitter (VerifyingPaymaster covers the account gas; the broker EOA fronts the outer tx, reimbursed). Submits via `cast send` (the repo's chain-mutation pattern; E8 proved the handleOps incantation on mainnet; the broker host ships foundry). handlers/accept.rs: - SubmitAcceptRequest (mirror; user_op.signature now carries the K11 assertion). - cast_handleops_arg (pure, tested) — WireUserOp → the cast PackedUserOperation tuple for handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address). - accept_submit (axum): J1 auth + cast send handleOps + parse {tx_hash, block_number}. - Route POST /v1/accept/submit wired. 5 unit tests green; clippy clean. NOTE: --private-key is ps-visible — production should move the submitter to the broker fee-payer keystore (follow-up, commented in code). Next: slice 4 daemon accept wiring (build → browser K11-sign → submit). Refs #225. * feat: #225 E7 (2b slice 4) — daemon /v1/accept/{build,submit} proxy routes The daemon bridge for the Touch-ID-gated accept: the browser calls build → does navigator.credentials.get() (Touch ID) over the returned userOpHash → calls submit. The daemon forwards both to the broker with the master J1; device fields come from the broker's AUTHORITATIVE pending binding (never the browser), scope from the UI. ui_bridge.rs: - accept_build_proxy: resolve broker + J1 + operator_omni (master session) + the binding row (agent_pending_value → child_omni/device_key_hash/pop_sig) → POST broker /v1/accept/build → relay {user_op, user_op_hash, entry_point, chain_id}. - accept_submit_proxy: relay the K11-signed op → broker /v1/accept/submit. - forward_to_broker helper (bearer J1, verbatim status+body relay). - Routes /v1/accept/{build,submit} wired. cargo build + clippy green. (link_code_redemption "0x" — accepted-but-unused by registerAgentDevice.) Next: slice 5 — the browser ceremony in ceremony.tsx/pairing.tsx (call build → Touch ID → submit). Refs #225. * feat: #225 E7 (2b slice 5) — browser Touch ID accept ceremony The accept button now does the REAL Touch-ID gate, replacing the deployer-signed registerPairing: build → navigator.credentials.get() over the userOpHash → submit. - webauthn.ts: getAssertionOverHash(userOpHash) — navigator.credentials.get with the userOpHash AS the raw WebAuthn challenge (arch.md §22b.1, no sha256 wrap), returning the raw assertion {authenticator_data, client_data_json, signature, credential_id}. - client (types/daemon/empty): acceptBuild + acceptSubmit (POST the daemon /v1/accept/{build,submit} proxies). - App.tsx acceptPairing: derive the approved memory:<ns> services from the request → acceptBuild → Touch ID over user_op_hash → acceptSubmit({user_op, assertion}). tsc clean. FINAL MILE (documented): the broker /v1/accept/submit must encode the assertion into the P256Account UserOp signature (abi.encode(credIdHash, authData, clientDataJSON, loc, r, s) — the same format the Rust CLI's k11_webauthn / erc4337-master-e8.sh already produce) before handleOps; today it expects a pre-signed op. That + the live cutover + hardware Touch ID are the remaining operator-side verification. Refs #225. * feat: #225 E7 — encode_webauthn_signature (the browser-assertion → UserOp signature) The crypto bridge slice 5 needs: encode the browser's WebAuthn assertion into the P256Account UserOp signature so EntryPoint.handleOps accepts it. core::erc4337::encode_webauthn_signature = abi.encode(bytes32 credIdHash, bytes authenticatorData, bytes clientDataJSON, uint256 challengeLocation, uint256 r, uint256 s) — the exact P256Account.validateUserOp format (== the CLI's k11 webauthn-userop-sign + erc4337-master-e8.sh byte spec). Golden-tested vs `cast abi-encode` (fixture committed). Reusable by both the broker accept_submit and the CLI. cargo test + clippy green. Next: wire it into the broker accept_submit (DER r/s extraction + cred_id_hash). Refs #225. * fix: heima-bring-up.sh FORCE_DEPLOY=1 now actually forces a redeploy (was a no-op) Reported by an operator running `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` for the #225 account-auth cutover: it printed "ALL 4 contracts already deployed → skip deploy" and did NOTHING, despite FORCE_DEPLOY=1. Root cause: step 5's `if [ "$ALL_DEPLOYED" = "1" ]` skip fired UNCONDITIONALLY when all 4 stored addresses had on-chain code — FORCE_DEPLOY was only consulted in the ELSE branch's unknown-address (0x0/empty) refusal, never for the has-code case. So FORCE_DEPLOY=1 could never force a redeploy of existing contracts — exactly what the cutover needs (redeploy the account-auth v2 set to NEW addresses). Fix: gate the skip on `&& [ "${FORCE_DEPLOY:-0}" != "1" ]`, so FORCE_DEPLOY=1 falls through to the deploy branch (the unknown-address refusal stays unchanged — DEPLOY_REASON is empty when ALL_DEPLOYED=1, so no false refusal). Added a LOUD warning that a force-redeploy over existing on-chain code orphans the old contracts + all their state (registrations/scopes/epoch/audit) and costs real mainnet gas. Normal/CI runs (no FORCE_DEPLOY) are unchanged — they still skip. env_set already clobbers, so the new addresses persist. Folded the fix note into the cutover spec Phase 1. bash -n clean. * docs: cutover script — QUICK START + all session findings in the header heima-cutover-account-auth.sh now opens with a copy-paste QUICK START (the full 6-step operator sequence: redeploy → verify → re-register master → broker redeploy → re-bind agents via the #225 accept flow → doc commit) and a FINDINGS block: - FORCE_DEPLOY fix (it was a no-op when contracts had on-chain code; Phase 1 depends on the fix); - SEQUENCING (run only after the #225 accept flow is wired, else agents are stranded post-cutover — the pre-cutover heima-agent-create/heima-scope-set scripts stop working); - ACCEPT-FLOW FINAL MILE (accept_submit must encode the assertion via the golden- tested encode_webauthn_signature; + new broker env + a funded submitter); - DECOUPLING (master-as-account works on TODAY's contracts; you may not need the cutover yet — try the non-destructive path first). Also corrected the stale Phase-4 line (re-bind is the accept flow, not the old deployer-EOA scripts) and widened the --help range (2,68) so it prints the full header. bash -n clean; --help verified. * docs: cutover operator runbook — Quick start block + session findings Added to docs/operator-runbook-account-auth-cutover.md: - ## Quick start — the copy-paste 6-step sequence (redeploy → verify → broker redeploy → re-register master → re-bind via accept flow → doc commit). - ⚠ Status blockquote — the #225 accept flow's FINAL MILE is open: accept_submit must encode the assertion via the golden-tested encode_webauthn_signature (+ the cred_id_hash derivation), plus new broker env (ENTRYPOINT/PAYMASTER/ BROKER_SPONSOR_SIGNER_*) + a funded submitter. So steps 1-4 work today; step 5 (re-bind agents) does not yet. - FORCE_DEPLOY-fix finding at step 1 (the flag was a no-op when contracts had code; fixed 2026-06-08 — need a checkout with the fix or the cutover silently no-ops). * fix: don't blind-redeploy when expected code is live; enforce deploy→doc-sync Two operator-reported gaps: 1. "If the expected code is already on-chain, we should not redeploy." My earlier FORCE_DEPLOY fix made `FORCE_DEPLOY=1 heima-bring-up.sh` a BLIND force — it redeploys whenever the stored address has code, without checking it's the EXPECTED version. heima-bring-up.sh can't robustly know "expected" (bytecode comparison is fragile — metadata hash, immutables). The SEMANTIC check belongs in the cutover script, which already has it (the live-scope setScope-selector probe → skip if account-auth is already live). So: the FORCE_DEPLOY warning now redirects to scripts/heima-cutover-account-auth.sh (the idempotent path that skips when the expected code is live) and is explicit that FORCE_DEPLOY is blind. 2. "As long as we deploy a new contract we need to update deployed-contracts.md and arch.md." A deploy updates operator-workstation.env (env_set) but NOT the canonical docs. heima-bring-up.sh now prints a loud "FRESH DEPLOY — update deployed-contracts.md + arch.md §5" reminder on every real deploy (DEPLOYED_FRESH flag). Added the matching HARD rule to CLAUDE.md "Deployed contract registry" (both directions: deploy→doc-sync, and don't-redeploy-if-expected-code-live), noting a future CI guard should diff deployed-contracts.md vs the env addresses. bash -n clean. * ci: enforce deployed-contracts.md ⊇ operator-workstation.env contract addresses Folds the "a new deploy must update the canonical docs" rule into ACTUAL CI enforcement (not just a reminder + a CLAUDE.md note): - scripts/check-deployed-contracts-sync.sh: sources operator-workstation.env, and for each prod-Heima contract key (scope/registry/epoch/audit/p256-verifier/ k11-verifier/entrypoint/factory) verifies its non-sentinel address appears in docs/spec/deployed-contracts.md (case-insensitive). Exits 1 listing any missing. Verified locally: all 8 in sync → exit 0. - .github/workflows/deployed-contracts-sync.yml: runs the check on PRs touching the env, the registry doc, or the check itself (+ push to main). - CLAUDE.md "Deployed contract registry": the rule now says CI ENFORCES it (was "a future CI guard should…"). So a cutover/redeploy that lands a new address in the env now fails the PR until the registry + arch.md are updated — closing the drift the operator flagged. bash -n clean. * agentkeys: fold contract registry into the chain profile + record v0.1 The chain profile crates/agentkeys-core/chain-profiles/<chain>.json is now the single machine-readable source of truth for deployed addresses + the deployed contract-set version (contract_set_version). heima-bring-up.sh rewrites it programmatically on every fresh deploy and gates redeploys on VERSION, not bytecode. Records the force-redeployed account-auth set as version 0.1 and propagates the #164/#225 account-auth cutover everywhere it had drifted. Registry / versioning: - ChainProfile gains contract_set_version (Option<String>); heima.json now carries all 8 contracts (added EntryPoint + P256AccountFactory) + version 0.1. - Pinning test reframed to validate shape + completeness + version (not exact addresses, which the deploy script now writes) -> no per-deploy test churn. - heima-bring-up.sh: VERSION (expected) vs profile contract_set_version (recorded) drives the deploy decision; a mismatch while code is live is a hard stop (no silent mainnet re-mint). Step 6b jq-writes the profile on deploy. - crates/agentkeys-chain/VERSION (new) = 0.1, the expected source version. - Dropped docs/spec/deployed-contracts.json (folded into the profile) + the per-PR CI workflow (local check only -- CI is expensive). - check-deployed-contracts-sync.sh rewritten: chain profile <-> env. - deployed-contracts.md: address table -> pointer to the profile; prose kept. Account-auth cutover propagation (the live AgentKeysScope is now setScope): - audit_decode scope.grant -> setScope (sel 0xd8e9e3c6); calldata::REGISTRY retains setScopeWithWebauthn only for orphaned pre-cutover calldata decode. - New core addresses across env + heima.json + the audit-decode test. - deployed-contracts.md SidecarRegistry/AgentKeysScope ABI summaries aligned to the live account-auth source. Tests: agentkeys-core 165+3 green; daemon audit_decode 4 green; check-deployed-contracts-sync.sh green (8 contracts, version 0.1). * docs: account-auth cutover runbook — add the accept-flow Solution (was only the problem) The ⚠ Status block named the final-mile gap but gave operators no path out of the `POST /v1/accept/build → 503 BROKER_SPONSOR_SIGNER_KEY not set` (+ no Touch-ID prompt). Adds a brief, actionable Solution: (1) broker env that unblocks build + the Touch-ID prompt (BROKER_SPONSOR_SIGNER_{KEY,ADDRESS} + PAYMASTER_ADDRESS on the host; ENTRYPOINT_ADDRESS_HEIMA already set), with the VerifyingPaymaster-not-deployed gotcha and the sponsored-vs-unsponsored fork; (2) wire accept_submit -> encode_webauthn_signature + cred_id_hash; (3) funded submitter EOA. Step 1 unblocks Touch-ID today; 2-3 the on-chain accept. * agentkeys: unsponsored accept keystone — /v1/accept/build works without a paymaster The accept flow's /v1/accept/build 503'd on `BROKER_SPONSOR_SIGNER_KEY not set` and required PAYMASTER_ADDRESS, but the VerifyingPaymaster is intentionally not deployed — so no Touch-ID prompt ever appeared. Align the broker to the mainnet-proven UNSPONSORED direct-handleOps model (erc4337-register-master.sh): - load_accept_config: PAYMASTER_ADDRESS + BROKER_SPONSOR_SIGNER_ADDRESS are now OPTIONAL. Unset PAYMASTER => unsponsored (the default); the beneficiary defaults to the submitter key's own address. Only BROKER_SPONSOR_SIGNER_KEY (the funded submitter EOA) is required. - assemble_accept_userop / AcceptUserOpParams: paymaster is Option; None => empty paymasterAndData, no broker co-sign, zero paymaster_get_hash; userOpHash over the empty-paymaster op. So once the operator sets BROKER_SPONSOR_SIGNER_KEY on the broker host, /v1/accept/build returns 200 and the browser Touch-ID ceremony runs. Runbook: the account-auth cutover Solution now reflects the unsponsored default (one env var) + the deeper on-chain-submit findings (synthetic credIdHash convention keccak("agentkeys-register-cred:0x<omni>"), the same-passkey prerequisite, the account EntryPoint deposit) for the follow-up submit wiring. Tests: broker lib 208 passed (new unsponsored_* tests + updated sponsored tests). The on-chain submit (decode -> handleOps) + the cross-stack wire are the remaining #225 final-mile; a live run needs hardware Touch ID + a funded EOA. * agentkeys: accept submit-encoding keystone — credId convention + browser-assertion decoder The on-chain accept's missing piece: turn the browser WebAuthn assertion into the P256Account UserOp signature. Two tested, reusable pieces (no chain I/O): - core::erc4337::master_cred_id_hash(operator_omni) + MASTER_CRED_ID_PREFIX — the ONE definition of the synthetic credId convention the master account is created with (keccak("agentkeys-register-cred:0x"+omni)); the accept signature must carry the SAME hash or P256Account reverts UnknownSigner. Terminology source-of-truth: the erc4337-register-master.sh literal must match it. - broker::accept_assertion::encode_browser_assertion_signature — base64url decode + p256 DER->(r,s) + challenge-location + the operator-derived credId -> core's golden-tested encode_webauthn_signature. Mirrors the mainnet-proven CLI extract_chain_assertion (the p256 dep lives in the broker, not core). Tests: core erc4337 6 passed; broker accept_assertion 2 passed (round-trip + negative). Next: wire accept_submit (decode -> handleOps) + the protocol/daemon/browser shape, then deploy + fund + wire the VerifyingPaymaster (the sponsored gas path). * agentkeys: wire accept_submit — broker encodes the browser assertion → handleOps The on-chain submit now lands: /v1/accept/submit carries the raw browser WebAuthn assertion ({user_op, assertion}); the broker encodes it into the P256Account UserOp signature and relays to EntryPoint.handleOps (sponsored — the paymaster covers gas). - broker accept_submit: decode assertion via accept_assertion::encode_browser_assertion_ signature → set user_op.signature → handleOps. operator_omni (→ the credIdHash signer key) is derived from the VERIFIED J1 session omni, not a body field — authoritative + unspoofable, and the browser already sends {user_op, assertion} (no frontend change). - protocol: SubmitAcceptUserOpRequest += assertion (new AcceptAssertion type); fixtures + frozen key-set test updated (keys: assertion, user_op) + the committed JSON regenerated. - daemon accept_submit_proxy is a verbatim relay (untyped) — no change needed. Tests: broker lib 210 passed; backend-client 12 passed (frozen + fixture --check green). Remaining for live: deploy + fund + wire the VerifyingPaymaster (sponsored gas path) + the broker BROKER_SPONSOR_SIGNER_KEY env; then a live run (hardware Touch ID + funded paymaster + a browser-registered master). * agentkeys: paymaster route — VerifyingPaymaster deploy helper + heima-bring-up wiring The sponsored gas path for the one-Touch-ID accept (atomic executeBatch register+scope). The broker co-sign + sponsored assemble were already built (paymaster: Some); this adds the deploy + funding + the operator runbook. - scripts/heima-deploy-paymaster.sh (new, idempotent): forge-create VerifyingPaymaster (entryPoint, brokerSigner, owner); skip-if-on-chain-code; setBrokerSigner only on drift; deposit() only below threshold; generates ~/.agentkeys/broker-sponsor-signer.key (0600) if absent; records PAYMASTER_ADDRESS_<p> + BROKER_SPONSOR_SIGNER_ADDRESS_<p> into operator-workstation.env. --dry-run; result JSON on stdout, logs on stderr. - heima-bring-up.sh step 6c: ensure the paymaster after the cores (idempotent, NON-fatal — the broker degrades to unsponsored without it; ENABLE_PAYMASTER=0 to skip). So setup-heima.sh deploys it transitively (no step renumbering). - Runbook: the Solution is now the sponsored/paymaster route — what's landed (submit encoding + sponsored config + the deploy helper) + the operator steps (deploy/fund, broker env via a 0600 EnvironmentFile for the key, the same-passkey prerequisite). Flagged-not-done: setup-broker-host.sh auto-wiring of BROKER_SPONSOR_SIGNER_KEY + PAYMASTER_ADDRESS (private-key 0600-EnvironmentFile handling on the live host, untested here), and the live on-chain run (paymaster deployed+funded, browser-registered master, hardware Touch ID — operator actions). Scripts syntax-checked (bash -n). * fix: heima-deploy-paymaster.sh — deploy via cast send --create, not forge create The first run died silently right after "deploying…". Diagnosed (deployer 0xdE64… has 20.99 HEI, EntryPoint live, VerifyingPaymaster.sol compiles — so not funds / RPC / compile): forge 1.6/1.7's `forge create` does a pre-broadcast gas estimation that errors on Heima BEFORE sending (deployer nonce unchanged at 306 → no tx, no orphan), and the address-extraction grep then tripped pipefail+set-e, killing the script with no error shown. Fix: deploy via `cast send --create` (the repo's proven chain-mutation path) at the DETERMINISTIC CREATE address (deployer ‖ nonce via cast compute-address), `|| true` + on-chain has_code verify (mixHash-receipt-proof), dumping the full cast output on failure (no more silent exit). Mirrors erc4337-register-master.sh's cast-send posture. * agentkeys: wire the accept-flow broker env into setup-broker-host.sh (#225 sponsored) The broker is a systemd service (User=agentkeys, ProtectHome=true), so the sponsor key can NOT live in ~/.zshenv — systemd never sources a shell rc + ProtectHome hides home dirs. And a hand-edited unit is wiped on the next setup-broker-host.sh (it regenerates the unit). So wire the accept-flow env into the generator (mirrors the $CRED_LINE conditional-line pattern): - Environment=ENTRYPOINT_ADDRESS_HEIMA / PAYMASTER_ADDRESS_HEIMA — public addresses from the sourced operator-workstation.env, emitted CONDITIONALLY (no empty PAYMASTER= line, which would break load_accept_config into a 503). - EnvironmentFile=-/etc/agentkeys/broker-sponsor.env — the SECRET BROKER_SPONSOR_SIGNER_KEY, operator-owned 0600 (systemd reads it as root pre-privilege-drop; /etc is outside ProtectHome). The `-` makes it optional: absent => broker boots UNSPONSORED, no hard-fail. Never inline Environment= (systemctl show + /proc/<pid>/environ would leak it). Runbook step 2 rewritten: ~/.zshenv won't work; the 0600 EnvironmentFile + the setup-broker-host.sh --ref main redeploy. bash -n + unit-render simulated (paymaster set/unset). * docs: runbook — extract the sponsor key VALUE (not cat ~/.zshenv) into broker-sponsor.env ~/.zshenv is a shell script, so `cat ~/.zshenv` would dump every line into the systemd EnvironmentFile and malform it. Document the value-extraction (zsh -c 'print -r -- $VAR', since zsh auto-sources ~/.zshenv) + a verify line (exactly one BROKER_SPONSOR_SIGNER_KEY=0x<64hex>), and flag the hard requirement: the key must be the SAME one whose address is the paymaster's on-chain brokerSigner, or the paymaster rejects the co-sign (AA34). * fix: heima-deploy-paymaster.sh — single --create value (cast variadic ate the flags) The deploy never landed (PAYMASTER_ADDRESS_HEIMA still unset, no paymaster on-chain). Root cause: `cast send --create <CODE> "constructor(...)" <args…>` is variadic in ARGS, so cast swallowed the trailing --private-key/--rpc-url/--legacy/--gas-limit flags as constructor args (same parse error `cast estimate` throws). Pre-concatenate the ABI-encoded constructor args onto the creation bytecode and pass ONE --create value, so no positionals follow and the flags parse cleanly. Deploy verified VIABLE off-chain: `cast estimate --create <init‖ctor>` = 692418 gas (deployer holds 20.99 HEI). * fix: heima-deploy-paymaster.sh — cast send flags FIRST, --create LAST The previous fix still failed: `cast send --create <DATA> --private-key …` → "unexpected argument '--private-key'". cast's `[SIG] [ARGS]…` positionals follow --create's value and are variadic, so they swallow any trailing flags. Verified: `cast send <flags> --create <DATA>` (nothing after --create) parses cleanly (reaches the RPC, not a parse error). The deploy estimate is 692418 gas; this was purely a cast arg-ordering bug. * fix: heima-deploy-paymaster.sh — --gas-limit on deposit()/setBrokerSigner (Heima estimate) The paymaster DEPLOYED fine (0xca3655…, brokerSigner matches) but deposit() didn't land: the deposit cast send had no --gas-limit, so cast fell back to eth_estimateGas — Heima's flaky path (same reason forge create broke). The deploy line already pins --gas-limit; add it to deposit() + setBrokerSigner (mirrors erc4337-register-master.sh, which always pins --gas-limit on Heima). Also: stop swallowing the deposit error (>/dev/null||true) — capture + dump it, verify by on-chain getDeposit re-read (mixHash-receipt-proof). Re-run is idempotent: paymaster has code => skip deploy, retry the deposit only. * agentkeys: record VerifyingPaymaster 0xca3655… (deployed + funded, #225 sponsored accept) The paymaster is live on Heima mainnet (deploy + 0.2 HEI EntryPoint deposit, brokerSigner = 0x0298Edd…). Record it so the broker can pull the address: - operator-workstation.env: PAYMASTER_ADDRESS_HEIMA + BROKER_SPONSOR_SIGNER_ADDRESS_HEIMA (the broker's accept handler + setup-broker-host.sh read these). - heima.json contracts[] + the pinning test expected-set + check-deployed-contracts-sync.sh mapping + deployed-contracts.md prose. Tests: chain_profile 19 passed; check-deployed-contracts-sync.sh green (9 contracts in sync). * docs: runbook — sponsored accept as 3 idempotent steps (local → broker → web) Shrink the setup to: 1) LOCAL deploy+record+commit (heima-deploy-paymaster.sh, idempotent; git add+push so the broker can pull), 2) REMOTE broker sponsor key + setup-broker-host.sh --ref <branch> + restart, 3) LOCAL dev.sh web test + accept. Calls out the three silent gotchas the operator hit: PAYMASTER_ADDRESS is NOT in broker-sponsor.env (secret-key-only; the address rides operator-workstation.env → the unit Environment=), --ref is required and must be the committed branch (not main), and the sponsor key must match the paymaster's brokerSigner. Verify via `systemctl show`, not a bare shell var. * feat: heima-deploy-paymaster.sh auto-records + commits a fresh deploy A fresh paymaster deploy now self-records: it writes the chain profile (jq add/replace VerifyingPaymaster in contracts[], idempotent — no duplicate) and AUTO-commits + pushes operator-workstation.env + the profile. This closes the footgun that left the broker without PAYMASTER_ADDRESS — the broker only sees COMMITTED values, and forgetting the manual commit/push silently broke the flow. - FRESH flag set only on a real deploy; a SKIP (already-deployed re-run) commits nothing. - Surgical: commits ONLY the env + profile paths (not other working-tree changes). - Opt out: PAYMASTER_NO_COMMIT=1. Non-fatal: deploy already landed on-chain; a VCS hiccup warns + tells you to `git push` by hand. Runbook step 1 updated (drop the manual git add/commit/push). * feat(ui): pairing requests show the real pairing code + a formatted timestamp The pairing-request UI showed neither — `pairCode` was a truncated request_id and `requestedAt` was the hardcoded string "awaiting on-chain approval". The data already exists in the broker's pairing_requests table (pairing_code TEXT, created_at INTEGER); it just wasn't plumbed. Vertical slice, no schema change: - broker: PendingBinding += pairing_code + created_at; pending_bindings() SELECTs them; /v1/agent/pending-bindings returns them. - daemon: pending_binding_to_request emits pairCode = the REAL agent code (so the operator can confirm it matches the device) and requestedAt = created_at unix seconds. - ui: PairingRequest.requestedAt: string→number; pairing.tsx shows a "pairing code" row + formats the timestamp (new Date(ts*1000).toLocaleString()). Tests: broker pairing 18 passed; daemon builds; frontend tsc clean. * docs+ui: pairing identifiers spec + mark declared-vs-attested in the UI Addresses three points on the pairing-request screen: 1. Safe to display? Yes — documented per field in the new spec. device_key_hash/D_pub are public on-chain identity (showing them IS the cross-verify point); pairing_code is one-time + already consumed by the time it reaches the master's pending list; request_id is useless without the agent's K10 pop_sig. 2. New spec docs/spec/agent-pairing-data-model.md — the §10.2 flow + every identifier (pairing_code 144b master-claim secret vs request_id 192b agent poll-ticket vs the attested device_key_hash/D_pub), secret/public, lifecycle, and the master-UI mapping. Indexed from arch.md §10.2. 3. device/machine/runtime are DECLARED (self-reported — in fact daemon placeholders), NOT attested. pairing.tsx now groups them under "⚠ declared by the runtime · self-reported, NOT attested" and the device_key_hash/D_pub under "✓ attested cryptographic identity · cross-check on the agent". Approve on the attested identity, never the declared labels. tsc clean. * docs: coherent runbook rewrite — entry-script map + explicit broker-sponsor.env Reconciles the two competing "start here" stories and spells out the broker secret file: - Entry-scripts table (cutover / paymaster / broker / web) maps each script to its phase + idempotency, so heima-cutover-account-auth.sh is clearly the phase-0 contract-redeploy entry (not a second quick-start) and the 3-step sponsored-accept is the live flow on top. - "What /etc/agentkeys/broker-sponsor.env must contain" table: BROKER_SPONSOR_SIGNER_KEY (required secret) + PAYMASTER_ADDRESS_HEIMA (required for sponsored) + optional ENTRYPOINT / SPONSOR_ADDRESS. Documents that EnvironmentFile= OVERRIDES the unit Environment= — so pinning PAYMASTER here is the reliable path, no commit→push→--ref dance. - Drops the stale "final mile is open" status + the duplicate cutover Quick start; folds the cutover into §A. Adds a Troubleshooting section (503 / invalid-value / AA34 / UnknownSigner / forge-create) covering every trap hit during bring-up. Step 1 documents the script's real auto-record + auto-commit behavior. * fix: accept web demo — broker finds cast (502) + auto-select the master passkey Two blockers in the live web accept: 1. 502 "spawn cast: No such file or directory" — accept_submit shells `cast send handleOps`, but the broker (systemd, User=agentkeys, ProtectHome=true) can't see a ~/.foundry/bin cast. - accept.rs: resolve cast via AGENTKEYS_CAST_BIN (default "cast"), with a helpful error. - setup-broker-host.sh: copy the operator's cast → /usr/local/bin (on the systemd PATH, outside home) + pin Environment=AGENTKEYS_CAST_BIN; warn if foundry isn't installed. 2. Passkey picker — getAssertionOverHash had no allowCredentials, so the browser offered every localhost passkey and the user had to guess (wrong one → on-chain rejection). Now the master credential id is stored at K11 enrollment (ceremony.tsx → localStorage ak_master_cred_id) and passed as allowCredentials at accept (App.tsx → getAssertionOverHash), so the browser auto-selects the right key. Absent ⇒ full picker (fallback). Runbook: troubleshooting entries for both. Tests: broker accept 16 passed; frontend tsc clean. * fix: real pairing decline (was UI-only) + broker auto-installs foundry for accept-submit Decline was a no-op — it filtered the local list but never told the broker, so the request reappeared on refresh. Now a real cross-stack decline (J1-gated, NO Touch ID — declining isn't an on-chain mutation): - broker: PairingRequestStore::decline (DELETE the claimed-but-unbound row, scoped to the claiming operator_omni) + POST /v1/agent/pairing/decline handler + route. - daemon: /v1/agent/pairing/decline proxy (untyped relay → forward_to_broker, like accept). - frontend: client.declinePairing + App.declinePairing actually calls it, then refreshes. cast/foundry on the broker (the recurring 502 'spawn cast'): setup-broker-host.sh now AUTO-INSTALLS foundry when cast is missing (idempotent, non-fatal), copies cast → /usr/local/bin (on the systemd PATH; ProtectHome hides ~/.foundry), and pins AGENTKEYS_CAST_BI…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two strands developed together on this branch:
Broker (deploy-relevant)
crates/agentkeys-broker-server/src/sponsor.rs(Stage A): the verifiable encoding + co-sign core for aVerifyingPaymaster-sponsored UserOp — pure functions, no chain client.user_op_hash ≡ EntryPoint.getUserOpHash,paymaster_get_hash ≡ VerifyingPaymaster.getHash, andbroker_cosignrecovers tobrokerSigner. The broker EIP-191-co-signs the paymastergetHash(the Sybil gate) only for an authenticated J1 session.lib.rs:+pub mod sponsor;. No broker route/handler changed — the running broker's HTTP behavior is unchanged; the module is exported for the sponsored-register flow but not yet wired to a live endpoint.EntryPoint.handleOpssubmission — needs an EVM client) is a follow-up, not in this PR.CLI / daemon
k11webauthn passkey keygen/sign + the sponsored-register flow.ui_bridgewires the flow into the desktop UI.Harness + docs
harness/v2-demo.sh: single 5-phase front door (1-3 stages, 4 memory-plant, 5 wire) withPHASE.STEPaddressing (--from 4.1,--only 3.11). Sandbox auto-detect now probes the aiosandbox HTTP API ($SANDBOX_URL/healthz‖/v1/sandbox) instead of a local openviking install.v2-stage3: agent-side steps (11-12 / 14-15)deferto the sandbox on the operator (green, never fail); mock agent is CI-only. Clearer stale-broker guidance on the feat(broker,worker): skip scope check for master-self (operator==actor) #195 master-self step.v2-stage1/2: Touch-ID WebAuthn by default for operators, stub for CI.harness/CLAUDE.md(harness rules extracted from rootCLAUDE.md), operator runbooks (operator-runbook-harness.md,operator-runbook-web-memory.md), anderc4337register/fund helpers.Deploy note
The step-16 (#195 master-self scope skip) fix is already on
origin/main(commit5bd3bf0) — the prod broker just needs a redeploy (bash scripts/setup-broker-host.sh --ref mainon the broker host). This PR adds the dormantsponsormodule on top; redeploying after merge picks it up with no behavior change to existing routes.Verification
cargo check(broker + cli + daemon): ✅cargo test -p agentkeys-broker-server: ✅ 7 passed (SES integration test ignored — needs live AWS)bash -non all touched harness scripts: ✅🤖 Generated with Claude Code